rindy 2 lat temu
rodzic
commit
4a279f6255

+ 1 - 1
package.json

@@ -1,7 +1,7 @@
 {
   "name": "@kankan/smart-bim",
   "private": true,
-  "version": "1.0.0",
+  "version": "4.4.4",
   "scripts": {
     "serve": "vue-cli-service serve",
     "build": "vue-cli-service build",

+ 4 - 2
public/smart-kankan.html

@@ -25,7 +25,9 @@
             <strong>We're sorry but doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
         </noscript>
         <div id="app"></div>
-        <script src="<%= BASE_URL %><%= VUE_APP_SDK_DIR %>kankan-sdk-deps.js?v=<%= VUE_APP_VERSION %>"></script>
-        <script src="<%= BASE_URL %><%= VUE_APP_SDK_DIR %>kankan-sdk.js?v=<%= VUE_APP_VERSION %>"></script>
+        <!-- <script src="<%= BASE_URL %><%= VUE_APP_SDK_DIR %>kankan-sdk-deps.js?v=<%= VUE_APP_VERSION %>"></script>
+        <script src="<%= BASE_URL %><%= VUE_APP_SDK_DIR %>kankan-sdk.js?v=<%= VUE_APP_VERSION %>"></script> -->
+        <script src="http://192.168.0.80:3099/dist/sdk/kankan-sdk-deps.js?v=<%= VUE_APP_VERSION %>"></script>
+        <script src="http://192.168.0.80:3099/dist/sdk/kankan-sdk.js?v=<%= VUE_APP_VERSION %>"></script>
     </body>
 </html>

+ 3 - 1
public/smart-viewer.html

@@ -6,9 +6,11 @@
         <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0" />
         <link rel="icon" type="image/svg+xml" href="//4dkk.4dage.com/FDKKIMG/icon/kankan_icon.ico" />
         <link rel="stylesheet" href="<%= VUE_APP_STATIC_DIR %>/ext/iconfont/iconfont.css" />
-        <link rel="stylesheet" href="//at.alicdn.com/t/c/font_3693743_j4ly3fuf3y.css" />
+        <link rel="stylesheet" href="<%= VUE_APP_STATIC_DIR %>/ext/swiper/swiper.min.css" />
+        <link rel="stylesheet" href="//at.alicdn.com/t/c/font_3693743_hxuk44ksxw9.css" />
         <script src="<%= VUE_APP_STATIC_DIR %>/ext/mobile-detect.js"></script>
         <script src="<%= VUE_APP_STATIC_DIR %>/ext/base64.min.js"></script>
+        <script src="<%= VUE_APP_STATIC_DIR %>/ext/swiper/swiper.min.js"></script>
         <script src="<%= VUE_APP_STATIC_DIR %>/lib/three.js/build/three.min.js"></script>
     </head>
     <body>

Plik diff jest za duży
+ 10663 - 0
public/static/ext/swiper/swiper.js


Plik diff jest za duży
+ 13 - 0
public/static/ext/swiper/swiper.min.css


Plik diff jest za duży
+ 14 - 0
public/static/ext/swiper/swiper.min.js


+ 19 - 0
src/assets/index.scss

@@ -3,7 +3,26 @@
 *::after{
     box-sizing: border-box;
 }
+::-webkit-scrollbar {
+    width: 4px;
+    height: 1px;
+}
+
+::-webkit-scrollbar-thumb {
+    border-radius: 4px;
+    box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
+    background: #ccc;
+}
 
+::-webkit-scrollbar-thumb:hover {
+    background: #999;
+}
+
+::-webkit-scrollbar-track {
+    box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
+    border-radius: 4px;
+    background: #000000;
+}
 html {
     line-height: 1.15;
     -webkit-text-size-adjust: 100%;

+ 193 - 0
src/components/files/TagEditor.vue

@@ -0,0 +1,193 @@
+<template>
+    <div class="tag-editor">
+        <div class="tag-editor-content" :style="{ height: height + 'px' }">
+            <header>
+                <span>新建标注</span>
+                <i class="iconfont icon-close" @click="emits('action', null)"></i>
+            </header>
+            <article>
+                <div>
+                    <h4><span>*</span>资料名称</h4>
+                    <UiInput v-model="form.title" type="text" placeholder="请输入资料名称" :maxlength="20" />
+                </div>
+                <div>
+                    <h4><span>*</span>状态</h4>
+                    <UiInput v-model="form.status" type="select" placeholder="请选择处理状态" :data="data.status" />
+                </div>
+                <div>
+                    <h4>涉及的成员</h4>
+                    <UiSelectList v-model="form.members" placeholder="请选择需要通知的项目人员" :data="data.members" />
+                </div>
+                <div>
+                    <h4>描述</h4>
+                    <UiArea v-model="form.describe" type="text" placeholder="请输入描述" :maxlength="50" />
+                </div>
+                <div>
+                    <h4>上传附件</h4>
+                    <UiMedias />
+                </div>
+            </article>
+            <footer>
+                <button @click="onSubmit">提交</button>
+            </footer>
+        </div>
+    </div>
+</template>
+<script setup>
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+import browser from '@/utils/browser'
+import { http } from '@/utils/request'
+import UiArea from '../form/Area.vue'
+import UiInput from '../form/Input.vue'
+import UiMedias from '../form/medias'
+import UiSelectList from '../form/SelectList.vue'
+
+const projectId = browser.valueFromUrl('projectId') || 1
+const props = defineProps(['notify'])
+const emits = defineEmits(['action'])
+const height = ref(0)
+const form = ref({
+    title: '',
+    describe: '',
+    status: '',
+    members: [],
+})
+const data = ref({
+    status: [
+        { text: '待处理', value: 1 },
+        { text: '进行中', value: 2 },
+        { text: '未解决', value: 3 },
+        { text: '已解决', value: 4 },
+    ],
+    members: [],
+})
+const onSubmit = () => {
+    const tag = { ...props.notify.tag }
+    tag.title = form.value.title
+    tag.content = form.value.describe
+    tag.visiblePanos = tag.visiblePanos
+    console.log(tag)
+    // http.post(`smart-site/marking/addOrUpdate`, {
+    //     projectId,
+    //     userIds: form.value.members.map(item => item.value),
+    //     markingStatus: form.value.status,
+    //     markingTitle: form.value.title,
+    //     hotData:tag
+    // }).then(response => {
+    //     debugger
+    // })
+}
+const onResize = () => {
+    height.value = window.innerHeight - 90
+}
+onMounted(() => {
+    window.kankan.TagManager.focusTag(props.notify.sid, {
+        direction: 'left',
+        attrs: {
+            width: 450,
+            height: 400,
+        },
+    })
+    onResize()
+    window.addEventListener('resize', onResize)
+
+    http.post(`smart-site/projectTeam/select`, { projectId }).then(response => {
+        data.value.members = response.data.map(item => {
+            return {
+                text: item.userName,
+                value: item.userId,
+            }
+        })
+    })
+})
+
+onBeforeUnmount(() => {
+    window.removeEventListener('resize', onResize)
+})
+</script>
+<style lang="scss" scoped>
+.tag-editor {
+    pointer-events: all;
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    z-index: 10001;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+.tag-editor-content {
+    display: flex;
+    flex-direction: column;
+    width: 400px;
+    height: 400px;
+    background: rgba(27, 27, 28, 0.8);
+    box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
+    border-radius: 4px 4px 4px 4px;
+    border: 1px solid #000000;
+    backdrop-filter: blur(4px);
+    color: #fff;
+
+    header {
+        padding: 0 20px;
+        height: 60px;
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        color: #999;
+        font-size: 18px;
+        border-bottom: solid 1px rgba(255, 255, 255, 0.16);
+        span {
+            font-weight: bold;
+        }
+        i {
+            cursor: pointer;
+        }
+    }
+
+    article {
+        display: flex;
+        flex-direction: column;
+        flex: 1;
+        padding: 0 20px;
+        overflow: hidden;
+        overflow-y: auto;
+
+        > div {
+            margin-top: 20px;
+        }
+        h4 {
+            font-size: 16px;
+            color: #999;
+            font-weight: bold;
+            margin-bottom: 14px;
+            span {
+                color: #fa5555;
+                font-weight: normal;
+            }
+        }
+    }
+
+    footer {
+        display: flex;
+        height: 60px;
+        align-items: center;
+        justify-content: center;
+        border-top: solid 1px rgba(255, 255, 255, 0.16);
+        button {
+            cursor: pointer;
+            color: #fff;
+            width: 105px;
+            height: 34px;
+            background: #0076f6;
+            border-radius: 2px;
+            font-size: 14px;
+            border: none;
+            outline: none;
+        }
+    }
+}
+</style>

+ 148 - 0
src/components/files/TagItem.vue

@@ -0,0 +1,148 @@
+<template>
+    <div
+        @click="onClick"
+        :data-tag-id="props.tag.sid"
+        :style="{ lefts: `${props.tag.x}px`, tops: `${props.tag.y}px`, transform: `translate(${props.tag.x}px,${props.tag.y}px)`, display: props.tag.visible ? 'block' : 'none' }"
+        class="tag-item"
+    >
+        <div class="tag-icon">
+            <span>{{ props.index }}</span>
+        </div>
+    </div>
+</template>
+<script setup>
+const props = defineProps({
+    tag: {
+        type: Object,
+        required: true,
+    },
+    index: {
+        type: Number,
+        required: true,
+    },
+})
+
+const emits = defineEmits(['action'])
+
+const onClick = () => {
+    emits('action', { event: 'focus', sid: props.tag.sid, tag: props.tag })
+}
+</script>
+<style lang="scss" scoped>
+.tag-item {
+    cursor: pointer;
+    pointer-events: all;
+    display: none;
+    position: absolute;
+    width: 26px;
+    height: 26px;
+    margin-left: -13px;
+    margin-top: -13px;
+    z-index: 1;
+    &.active {
+        .tag-icon {
+            background-color: green;
+        }
+    }
+}
+
+.tag-item.focus {
+    z-index: 2;
+}
+.tag-item.focus .tag-body {
+    transform: translateY(-50%) scale(1);
+}
+.tag-item.fixed {
+    z-index: 3;
+}
+.tag-item.active {
+    z-index: 4;
+}
+
+.tag-item .tag-icon {
+    position: relative;
+    display: block;
+    width: 26px;
+    height: 26px;
+    border-radius: 0 50% 50% 50%;
+    border: 1px solid #fff;
+    transform: rotate(-135deg);
+    background-color: #0076f6;
+    span {
+        display: block;
+        transform: rotate(135deg);
+        margin-top: 3px;
+        margin-left: -1px;
+        text-align: center;
+    }
+}
+
+.tag-item .tag-icon.animate {
+    animation: tag-animate-zoom 3s -1s linear infinite;
+}
+
+.tag-item .tag-body {
+    position: fixed;
+    right: 0;
+    top: 50%;
+    margin-right: 70px;
+    width: 200px;
+    height: 200px;
+    transform: translateY(-50%) scale(1);
+    transform-origin: calc(100% + 40px) -50%;
+    background: rgba(27, 27, 28, 0.8);
+    border-radius: 4px;
+    min-width: 400px;
+    padding: 30px 20px;
+}
+
+.tag-item .tag-body::before {
+    content: '';
+    position: absolute;
+    width: 40px;
+    height: 100%;
+    top: 0;
+    right: -40px;
+}
+
+.tag-item .tag-body::after {
+    content: '';
+    position: absolute;
+    top: 50%;
+    right: -39px;
+    width: 0;
+    height: 0;
+    border-top: 15px solid transparent;
+    border-bottom: 15px solid transparent;
+    border-left: 40px solid rgba(27, 27, 28, 0.8);
+    transform: translateY(-50%);
+}
+
+@keyframes tag-animate-zoom {
+    0% {
+        transform: scale(1);
+    }
+    50% {
+        transform: scale(0.7);
+    }
+    100% {
+        transform: scale(1);
+    }
+}
+
+.tag-item .v-enter-from,
+.tag-item .v-leave-to {
+    opacity: 0;
+    transform: translateY(50%) scale(0);
+}
+.tag-item .v-enter-active,
+.tag-item .v-leave-active {
+    will-change: transform;
+    transition: all 0.25s cubic-bezier(0.35, 0.32, 0.65, 0.63);
+}
+.tag-item .v-enter-to,
+.tag-item .v-leave-from {
+    opacity: 1;
+    transform: translateY(-50%) scale(1);
+}
+</style>

Plik diff jest za duży
+ 108 - 0
src/components/files/TagManager.vue


+ 125 - 0
src/components/files/TagView.vue

@@ -0,0 +1,125 @@
+<template>
+    <div class="tag-view">
+        <div class="tag-view-content" :style="{ height: height + 'px' }">
+            <header>
+                <span>场景标注名称 </span>
+                <i class="iconfont icon-close" @click="emits('action', null)"></i>
+            </header>
+            <article>
+                <div>
+                    <div>
+                        
+                    </div>
+                </div>
+                <div></div>
+            </article>
+            <footer></footer>
+        </div>
+    </div>
+</template>
+<script setup>
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+
+const props = defineProps(['notify'])
+const emits = defineEmits(['action'])
+const height = ref(0)
+const form = ref({
+    title: '',
+    describe: '',
+    status: '',
+    members: [],
+})
+const onResize = () => {
+    height.value = window.innerHeight - 90
+}
+onMounted(() => {
+    window.kankan.TagManager.focusTag(props.notify.sid, {
+        direction: 'left',
+        // attrs: {
+        //     width: 450,
+        //     height: 400,
+        // },
+    })
+    onResize()
+    window.addEventListener('resize', onResize)
+})
+
+onBeforeUnmount(() => {
+    window.removeEventListener('resize', onResize)
+})
+</script>
+<style lang="scss" scoped>
+.tag-view {
+    pointer-events: all;
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    z-index: 10001;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+.tag-view-content {
+    display: flex;
+    flex-direction: column;
+    width: 740px;
+    height: 400px;
+    background: rgba(27, 27, 28, 0.8);
+    box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
+    border-radius: 4px 4px 4px 4px;
+    border: 1px solid #000000;
+    backdrop-filter: blur(4px);
+    color: #fff;
+
+    header {
+        padding: 0 20px;
+        height: 60px;
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        color: #999;
+        font-size: 18px;
+        border-bottom: solid 1px rgba(255, 255, 255, 0.16);
+        span {
+            font-weight: bold;
+        }
+        i {
+            cursor: pointer;
+        }
+    }
+
+    article {
+        display: flex;
+        width: 100%;
+        height: 100%;
+        > div {
+            width: 50%;
+            &:first-child {
+                border-right: solid 1px rgba(255, 255, 255, 0.16);
+            }
+        }
+    }
+
+    footer {
+        display: flex;
+        height: 60px;
+        align-items: center;
+        justify-content: center;
+        border-top: solid 1px rgba(255, 255, 255, 0.16);
+        button {
+            cursor: pointer;
+            color: #fff;
+            width: 105px;
+            height: 34px;
+            background: #0076f6;
+            border-radius: 2px;
+            font-size: 14px;
+            border: none;
+            outline: none;
+        }
+    }
+}
+</style>

+ 154 - 0
src/components/files/index.vue

@@ -0,0 +1,154 @@
+<template>
+    <transition name="slide-right" mode="in-out">
+        <div class="files" v-if="showFiles">
+            <ul>
+                <li>
+                    <button class="add" @click="onAdd">添加标注</button>
+                </li>
+            </ul>
+        </div>
+    </transition>
+    <transition name="slide-up" mode="in-out">
+        <div class="toolbar" v-if="showToolbar">
+            <button type="button" @click="onAddCancel">取消</button>
+            <button type="submit" @click="onAddConfirm">确定</button>
+        </div>
+    </transition>
+</template>
+<script setup>
+import { ref, watchEffect } from 'vue'
+
+const props = defineProps(['show'])
+const emits = defineEmits(['add'])
+
+const showFiles = ref(false)
+const showToolbar = ref(false)
+
+const onAdd = () => {
+    if (window.kankan) {
+        window.kankan.TagManager.editor.then(editor => {
+            editor.enter()
+            showFiles.value = false
+            showToolbar.value = true
+        })
+    }
+}
+const onAddCancel = () => {
+    showFiles.value = true
+    showToolbar.value = false
+    kankan.TagManager.editor.then(editor => editor.exit())
+}
+const onAddConfirm = () => {
+    showFiles.value = true
+    showToolbar.value = false
+    kankan.TagManager.editor.then(editor => {
+        var tag = editor.confirm()
+        if (tag) {
+            kankan.TagManager.add([tag])
+        }
+    })
+}
+watchEffect(() => {
+    showFiles.value = props.show
+})
+</script>
+<style lang="scss" scoped>
+.files {
+    position: absolute;
+    top: 57px;
+    right: 0;
+    width: 240px;
+    height: 100%;
+    background: rgba(27, 27, 28, 0.8);
+    z-index: 1000;
+}
+.toolbar {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    height: 60px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex: 1;
+    background-color: rgba(27, 27, 28, 0.8);
+    pointer-events: all;
+    z-index: 1000;
+    transition: all 0.3s ease;
+    button {
+        width: 160px;
+        height: 34px;
+        border: none;
+        outline: none;
+        border-radius: 4px;
+        font-size: 14px;
+        background: transparent;
+        transition: all 0.3s ease;
+        color: var(--color-main-normal);
+        border: solid 1px rgb(0, 118, 246);
+        color: rgb(0, 118, 246);
+        margin: 0 10px;
+        &[type='submit'] {
+            background: rgb(0, 118, 246);
+            color: #fff;
+        }
+    }
+}
+ul {
+    margin: 0 10px;
+}
+li {
+    margin-top: 20px;
+    list-style: none;
+}
+.add {
+    margin: 0;
+    padding: 0;
+    width: 100%;
+    height: 34px;
+    border: 1px solid rgba(255, 255, 255, 0.4);
+    color: rgba(255, 255, 255, 0.4);
+    border-radius: 4px;
+    &:hover {
+        border-color: #fff;
+        color: #fff;
+    }
+}
+
+.slide-right-enter-active,
+.slide-right-leave-active {
+    will-change: transform;
+    transition: all 0.2s ease-in-out;
+}
+.slide-right-enter-from {
+    opacity: 0;
+    transform: translate3d(100%, 0, 0);
+}
+.slide-right-enter {
+    opacity: 1;
+    transform: translate3d(-100%, 0, 0);
+}
+.slide-right-leave-active {
+    opacity: 0;
+    transform: translate3d(100%, 0, 0);
+}
+
+.slide-up-enter-active,
+.slide-up-leave-active {
+    will-change: transform;
+    transition: all 0.2s ease-in-out;
+}
+.slide-up-enter-from {
+    opacity: 0;
+    transform: translate3d(0, 100%, 0);
+}
+.slide-up-enter {
+    opacity: 1;
+    transform: translate3d(0, -100%, 0);
+}
+.slide-up-leave-active {
+    opacity: 0;
+    transform: translate3d(0, 100%, 0);
+}
+</style>

+ 70 - 0
src/components/form/Area.vue

@@ -0,0 +1,70 @@
+<template>
+    <div class="control">
+        <div class="component area">
+            <textarea  :maxlength="maxlength" :placeholder="placeholder" v-model="modelValue" @input="e => emits('update:modelValue', e.target.value)" ></textarea>
+            <div class="maxlength" v-if="maxlength">
+                <span>{{ modelValue.length }}</span
+                >&nbsp;/&nbsp;{{ maxlength }}
+            </div>
+        </div>
+    </div>
+</template>
+<script setup>
+const props = defineProps({
+    modelValue: {
+        type: String,
+        require: true,
+    },
+    maxlength: {
+        type: Number,
+        require: false,
+        default: null,
+    },
+    placeholder: {
+        type: String,
+        require: false,
+        default: '请输入',
+    },
+})
+const emits = defineEmits(['update:modelValue', 'change'])
+</script>
+<style lang="scss" scoped>
+ul,
+li {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.control {
+    background: rgba(255, 255, 255, 0.1);
+    border-radius: 4px 4px 4px 4px;
+    border: 1px solid rgba(255, 255, 255, 0.2);
+    .component {
+        position: relative;
+        width: 100%;
+        height: 100px;
+        display: flex;
+        flex-direction: column;
+    }
+    .area {
+        textarea {
+            width: 100%;
+            height: 100%;
+            padding: 10px;
+            color: #fff;
+            outline: none;
+            resize: none;
+        }
+        .maxlength {
+            white-space: nowrap;
+            margin-right: 10px;
+            margin-bottom: 10px;
+            color: #999;
+            text-align: right;
+            span {
+                color: #0076f6;
+            }
+        }
+    }
+}
+</style>

+ 161 - 0
src/components/form/Input.vue

@@ -0,0 +1,161 @@
+<template>
+    <div class="control" @click="onInputerClick">
+        <div class="component select" v-if="type == 'select'" v-click-outside="onOutside">
+            <div class="place" :class="{placeholder:!selecterText}">{{ selecterText || placeholder }}</div>
+            <div class="icon" :class="{up:selecterShow}">
+                <i class="iconfont icon-arrows_down"></i>
+            </div>
+            <div class="panel" v-show="selecterShow"  >
+                <ul>
+                    <li v-for="item in data" @click.stop="onselecterChange(item)">
+                        <div>{{ item.text }}</div>
+                    </li>
+                </ul>
+            </div>
+        </div>
+        <div class="component text" v-if="type == 'text'">
+            <input class="component text" :maxlength="maxlength" :placeholder="placeholder" v-model="modelValue" @input="e => emits('update:modelValue', e.target.value)" />
+            <div class="maxlength" v-if="maxlength">
+                <span>{{ modelValue.length }}</span
+                >&nbsp;/&nbsp;20
+            </div>
+        </div>
+    </div>
+</template>
+<script setup>
+import { ref } from 'vue'
+const props = defineProps({
+    type: {
+        type: String,
+        default: 'text',
+    },
+    data: {
+        type: Array,
+        default: [],
+    },
+    modelValue: {
+        type: [String , Number , Boolean],
+        require: true,
+    },
+    maxlength: {
+        type: Number,
+        require: false,
+        default: null,
+    },
+    placeholder: {
+        type: String,
+        require: false,
+        default: '请输入',
+    },
+})
+const emits = defineEmits(['update:modelValue', 'change'])
+
+const selecterText = ref('')
+
+const selecterShow = ref(false)
+
+const onInputerClick = () => {
+    if (props.type == 'select') {
+        selecterShow.value = !selecterShow.value
+    }
+}
+
+const onselecterChange = data => {
+    emits('update:modelValue', data.value)
+    emits('change', data)
+    selecterText.value = data.text
+    selecterShow.value = false
+}
+const onOutside = ()=>{
+    selecterShow.value = false
+}
+</script>
+<style lang="scss" scoped>
+ul,
+li {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.control {
+    background: rgba(255, 255, 255, 0.1);
+    border-radius: 4px 4px 4px 4px;
+    border: 1px solid rgba(255, 255, 255, 0.2);
+    .component {
+        position: relative;
+        width: 100%;
+        height: 34px;
+        display: flex;
+        align-items: center;
+    }
+    .text {
+        input {
+            width: 100%;
+            height: 100%;
+            padding: 0 10px;
+            color: #fff;
+        }
+        .maxlength {
+            white-space: nowrap;
+            margin-top: 2px;
+            margin-right: 10px;
+            color: #999;
+            span {
+                color: #0076f6;
+            }
+        }
+    }
+
+    .select {
+        cursor: pointer;
+        .place{
+            width: 100%;
+            height: 100%;
+            color: #fff;
+            line-height: 34px;
+            text-indent: 10px;
+            &.placeholder{
+                color:#757575;
+            }
+        }
+        .panel {
+            position: absolute;
+            left: -1px;
+            right: -1px;
+            top: calc(100% + 4px);
+            background: rgba(27, 27, 28, 0.8);
+            box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
+            border-radius: 4px 4px 4px 4px;
+            border: 1px solid #000000;
+            max-height: 200px;
+            overflow: hidden;
+            overflow-y: auto;
+            z-index: 1000;
+            li {
+                cursor: pointer;
+                height: 34px;
+                display: flex;
+                align-items: center;
+                &:hover {
+                    background: rgba(255, 255, 255, 0.1);
+                }
+                > div {
+                    text-indent: 10px;
+                }
+            }
+        }
+
+        .icon {
+            cursor: pointer;
+            margin-right: 10px;
+            &.up {
+                transform: rotate(180deg);
+            }
+            i {
+                font-size: 12px;
+                color: #999;
+            }
+        }
+    }
+}
+</style>

+ 197 - 0
src/components/form/SelectList.vue

@@ -0,0 +1,197 @@
+<template>
+    <div class="control" @click="onInputerClick">
+        <div class="component select" v-click-outside="onOutside">
+            <div class="place" :class="{ placeholder: !modelValue.length }">
+                <ul v-if="modelValue.length">
+                    <li v-for="item in modelValue" @click.stop>
+                        <span>{{ item.text }}</span
+                        ><i class="iconfont icon-close" @click="onselecterChange(item)"></i>
+                    </li>
+                </ul>
+                <div v-else>{{ placeholder }}</div>
+            </div>
+            <div class="icon" :class="{ up: selecterShow }">
+                <i class="iconfont icon-arrows_down"></i>
+            </div>
+            <div class="panel" v-show="selecterShow">
+                <ul>
+                    <li v-for="item in data" @click.stop="onselecterChange(item)">
+                        <div><span class="checkbox" :class="{ checked: modelValue.includes(item) }"></span>{{ item.text }}</div>
+                    </li>
+                </ul>
+            </div>
+        </div>
+    </div>
+</template>
+<script setup>
+import { ref } from 'vue'
+const props = defineProps({
+    data: {
+        type: Array,
+        default: [],
+    },
+    modelValue: {
+        type: Array,
+        require: true,
+    },
+    placeholder: {
+        type: String,
+        require: false,
+        default: '请输入',
+    },
+})
+
+const emits = defineEmits(['change'])
+
+const selecterShow = ref(false)
+
+const onInputerClick = () => {
+    selecterShow.value = !selecterShow.value
+}
+
+const onselecterChange = data => {
+    let index = props.modelValue.findIndex(item => item.value == data.value)
+    if (index == -1) {
+        props.modelValue.push(data)
+    } else {
+        props.modelValue.splice(index, 1)
+    }
+    emits('change',  props.modelValue)
+}
+const onOutside = () => {
+    selecterShow.value = false
+}
+</script>
+<style lang="scss" scoped>
+ul,
+li {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.control {
+    background: rgba(255, 255, 255, 0.1);
+    border-radius: 4px 4px 4px 4px;
+    border: 1px solid rgba(255, 255, 255, 0.2);
+    .component {
+        position: relative;
+        width: 100%;
+
+        display: flex;
+        align-items: center;
+    }
+    .select {
+        .place {
+            display: flex;
+            width: 100%;
+            min-height: 66px;
+            color: #fff;
+            align-items: flex-start;
+            &.placeholder {
+                color: #757575;
+                align-items: center;
+                justify-content: center;
+            }
+            ul {
+                margin-bottom: 6px;
+            }
+            li {
+                display: inline-block;
+                background-color: #767473;
+                border-radius: 4px;
+                padding: 4px 6px;
+                margin-top: 6px;
+                margin-left: 6px;
+                i {
+                    font-size: 12px;
+                    margin-left: 7px;
+                    cursor: pointer;
+                }
+            }
+        }
+        .panel {
+            position: absolute;
+            left: -1px;
+            right: -1px;
+            top: calc(100% + 4px);
+            background: rgba(27, 27, 28, 0.8);
+            box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
+            border-radius: 4px 4px 4px 4px;
+            border: 1px solid #000000;
+            max-height: 200px;
+            overflow: hidden;
+            overflow-y: auto;
+            z-index: 1000;
+            li {
+                cursor: pointer;
+                height: 34px;
+                display: flex;
+                align-items: center;
+                &:hover {
+                    background: rgba(255, 255, 255, 0.1);
+                }
+                > div {
+                    display: flex;
+                    align-items: center;
+                }
+            }
+        }
+
+        .icon {
+            cursor: pointer;
+            margin-right: 10px;
+            &.up {
+                transform: rotate(180deg);
+            }
+            i {
+                font-size: 12px;
+                color: #999;
+            }
+        }
+    }
+}
+.checkbox {
+    position: relative;
+    width: 16px;
+    height: 16px;
+    margin-right: 5px;
+    margin-left: 10px;
+    &::before {
+        content: '';
+        border: 1px solid #666;
+        border-radius: 2px;
+        width: 16px;
+        height: 16px;
+        position: absolute;
+        left: 0px;
+        top: 0;
+        display: inline-block;
+    }
+    &.checked {
+        &::before {
+            border: 1px solid #0076f6;
+            background-color: #0076f6;
+        }
+        &::after {
+            left: 4px;
+            top: 7px;
+            position: absolute;
+            display: table;
+            border: 2px solid #fff;
+            border-top: 0;
+            border-left: 0;
+            transform: rotate(45deg) translate(-50%, -50%);
+            opacity: 1;
+            transition: all 0.2s cubic-bezier(0.12, 0.4, 0.29, 1.46) 0.1s;
+            width: 6px;
+            height: 8px;
+            content: ' ';
+        }
+    }
+}
+
+.checkbox-label {
+    display: inline-block;
+    vertical-align: 3px;
+}
+</style>

+ 96 - 0
src/components/form/medias/Audio.vue

@@ -0,0 +1,96 @@
+<template>
+    <div class="media" v-show="media">
+
+    </div>
+    <div class="placeholder" @click="file.click()" v-show="media == null">
+        <div class="icon">
+            <i class="iconfont icon-add"></i>
+            <span>上传音频</span>
+        </div>
+        <div class="tips">支持 mp3/wav 文件:≤ 5MB</div>
+        <input ref="file" type="file" style="display: none" accept=".mp3, .wav" @change="onChange" />
+    </div>
+</template>
+<script setup>
+import { ref, onMounted } from 'vue'
+import { checkSizeLimitFree, base64ToDataURL } from '@/utils/file'
+
+const emits = defineEmits(['tips'])
+const file = ref(null)
+const media = ref(null)
+const onChange = e => {
+    if (!e.target.files.length) {
+        return
+    }
+
+    let file = e.target.files[0]
+
+    if (checkSizeLimitFree(file.size, 5)) {
+        let reader = new FileReader()
+        reader.onload = function () {
+            images.value.push(base64ToDataURL(reader.result))
+        }
+        reader.readAsDataURL(file)
+    } else {
+        emits('tips','请上传 5MB 以内的 jpg/png 文件')
+    }
+    e.target.value = ''
+}
+
+onMounted(() => {
+
+})
+</script>
+<style lang="scss" scoped>
+.placeholder {
+    cursor: pointer;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    .icon {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        flex-direction: column;
+        color: rgba(255, 255, 255, 0.6);
+        font-size: 14px;
+        span {
+            margin-top: 10px;
+        }
+    }
+    .tips {
+        font-size: 12px;
+        padding: 10px;
+        position: absolute;
+        bottom: 0;
+        width: 100%;
+        color: rgba(255, 255, 255, 0.3);
+    }
+}
+.media {
+    width: 100%;
+    height: 100%;
+    .add {
+        cursor: pointer;
+        font-size: 12px;
+        position: absolute;
+        bottom: 0;
+        left: 0;
+        width: 100%;
+        height: 32px;
+        line-height: 32px;
+        text-align: center;
+        background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), #000 200%);
+        border-radius: 0 0 4px 4px;
+        z-index: 10;
+        &.disable {
+            pointer-events: none;
+        }
+        span {
+            color: #0076f6;
+        }
+    }
+}
+</style>

+ 145 - 0
src/components/form/medias/Image.vue

@@ -0,0 +1,145 @@
+<template>
+    <div class="media-image" v-show="images.length">
+        <div class="swiper" ref="swiper$">
+            <div class="swiper-wrapper">
+                <div class="swiper-slide" v-for="(url, index) in images" :style="`background-image: url(${url})`" :key="index"></div>
+            </div>
+            <div class="swiper-button-prev"></div>
+            <div class="swiper-button-next"></div>
+        </div>
+        <div class="add" @click="file.click()" :class="{ disable: images.length >= 9 }">
+            继续添加&nbsp;<span>{{ images.length }}</span
+            >&nbsp;/&nbsp;9
+        </div>
+    </div>
+    <div class="placeholder" @click="file.click()" v-show="images.length == 0">
+        <div class="icon">
+            <i class="iconfont icon-add"></i>
+            <span>上传图片</span>
+        </div>
+        <div class="tips">支持JPG、PNG等图片格式,单张不超过5MB,最多支持上传9张。</div>
+        <input ref="file" type="file" style="display: none" accept="image/jpg,image/jpeg,image/png" @change="onChange" />
+    </div>
+</template>
+<script setup>
+import { ref, onMounted } from 'vue'
+import { checkSizeLimitFree, base64ToDataURL } from '@/utils/file'
+
+const emits = defineEmits(['tips'])
+
+const file = ref(null)
+const swiper$ = ref(null)
+
+const images = ref([])
+const onChange = e => {
+    if (!e.target.files.length) {
+        return
+    }
+
+    let file = e.target.files[0]
+
+    if (checkSizeLimitFree(file.size, 5)) {
+        let reader = new FileReader()
+        reader.onload = function () {
+            images.value.push(base64ToDataURL(reader.result))
+        }
+        reader.readAsDataURL(file)
+    } else {
+        emits('tips','请上传 5MB 以内的 jpg/png 文件')
+    }
+    e.target.value = ''
+}
+
+onMounted(() => {
+    let swiper = new Swiper(swiper$.value, {
+        observer: true,
+        navigation: {
+            prevEl: swiper$.value.querySelector('.swiper-button-prev'),
+            nextEl: swiper$.value.querySelector('.swiper-button-next'),
+        },
+        on: {
+            observerUpdate: function () {
+                swiper.slideTo(images.value.length - 1, 0, false)
+            },
+        },
+    })
+})
+</script>
+<style lang="scss" scoped>
+.placeholder {
+    cursor: pointer;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    .icon {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        flex-direction: column;
+        color: rgba(255, 255, 255, 0.6);
+        font-size: 14px;
+        span {
+            margin-top: 10px;
+        }
+    }
+    .tips {
+        font-size: 12px;
+        padding: 10px;
+        position: absolute;
+        bottom: 0;
+        width: 100%;
+        color: rgba(255, 255, 255, 0.3);
+    }
+}
+.media-image {
+    width: 100%;
+    height: 100%;
+    .swiper {
+        width: 100%;
+        height: 100%;
+        .swiper-slide {
+            text-align: center;
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            width: 100%;
+            height: 100%;
+            background-position: center center;
+            background-size: contain;
+        }
+        .swiper-button-prev,
+        .swiper-button-next {
+            width: 32px;
+            height: 32px;
+            background: rgba(0, 0, 0, 0.2);
+            border-radius: 50%;
+            &::after {
+                font-weight: bold;
+                font-size: 14px;
+            }
+        }
+    }
+    .add {
+        cursor: pointer;
+        font-size: 12px;
+        position: absolute;
+        bottom: 0;
+        left: 0;
+        width: 100%;
+        height: 32px;
+        line-height: 32px;
+        text-align: center;
+        background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), #000 200%);
+        border-radius: 0 0 4px 4px;
+        z-index: 10;
+        &.disable {
+            pointer-events: none;
+        }
+        span {
+            color: #0076f6;
+        }
+    }
+}
+</style>

+ 133 - 0
src/components/form/medias/Link.vue

@@ -0,0 +1,133 @@
+<template>
+    <div class="media" v-show="url">
+        <iframe v-if="url" :src="url" frameborder="0"></iframe>
+        <div class="delete" @click.stop="onDelete"><i class="iconfont icon-delete"></i></div>
+        <div class="link">{{ url }}</div>
+    </div>
+    <div class="placeholder" v-show="url == null">
+        <div class="icon">
+            <span>网页展示区</span>
+        </div>
+        <div class="link">
+            <input type="text" placeholder="https://" v-model.trim="href" />
+            <div class="save" @click="onConfirm"><i class="iconfont icon-checkbox1"></i></div>
+        </div>
+    </div>
+</template>
+<script setup>
+import { ref, onMounted } from 'vue'
+const emits = defineEmits(['tips'])
+const url = ref(null)
+const href = ref('')
+const onDelete = () =>{
+    url.value = null
+}
+const onConfirm = () => {
+    if (href.value) {
+        url.value = 'https://' + href.value.replace(/http(s?):\/\//, '')
+        href.value = ''
+    }
+}
+
+onMounted(() => {})
+</script>
+<style lang="scss" scoped>
+.placeholder {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    .icon {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        flex-direction: column;
+        color: rgba(255, 255, 255, 0.6);
+        font-size: 14px;
+        span {
+            margin-top: 10px;
+        }
+    }
+    .tips {
+        font-size: 12px;
+        padding: 10px;
+        position: absolute;
+        bottom: 0;
+        width: 100%;
+        color: rgba(255, 255, 255, 0.3);
+    }
+    .link {
+        position: absolute;
+        bottom: 0;
+        left: 0;
+        width: 100%;
+        height: 32px;
+        background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), #000 200%);
+        border-radius: 0 0 4px 4px;
+        z-index: 10;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        .save {
+            cursor: pointer;
+            width: 16px;
+            height: 16px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            border-radius: 50%;
+            background: hsla(0, 0%, 100%, 0.7);
+            color: rgba(0, 0, 0, 0.6);
+            margin: 0 8px;
+            i {
+                font-size: 16px;
+            }
+        }
+        input {
+            width: 100%;
+            height: 100%;
+            color: #fff;
+            font-size: 14px;
+            padding-left: 10px;
+        }
+    }
+}
+.media {
+    width: 100%;
+    height: 100%;
+    iframe {
+        width: 100%;
+        height: 100%;
+    }
+    .link {
+        padding: 0 12px;
+        font-size: 14px;
+        position: absolute;
+        bottom: 0;
+        left: 0;
+        width: 100%;
+        height: 32px;
+        line-height: 32px;
+        background: linear-gradient(180deg, rgba(0, 0, 0, 0.3), #000 200%) !important;
+        border-radius: 0 0 4px 4px;
+        z-index: 10;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+    }
+    .delete {
+        cursor: pointer;
+        position: absolute;
+        width: 24px;
+        height: 24px;
+        background: rgba(0, 0, 0, 0.3);
+        border-radius: 50%;
+        top: 10px;
+        right: 10px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+    }
+}
+</style>

+ 96 - 0
src/components/form/medias/Video.vue

@@ -0,0 +1,96 @@
+<template>
+    <div class="media" v-show="media">
+
+    </div>
+    <div class="placeholder" @click="file.click()" v-show="media == null">
+        <div class="icon">
+            <i class="iconfont icon-add"></i>
+            <span>上传视频</span>
+        </div>
+        <div class="tips">支持 mp4/mov 文件:≤ 20MB,≤ 2Mbps</div>
+        <input ref="file" type="file" style="display: none" accept=".mp4, .mov" @change="onChange" />
+    </div>
+</template>
+<script setup>
+import { ref, onMounted } from 'vue'
+import { checkSizeLimitFree, base64ToDataURL } from '@/utils/file'
+
+const emits = defineEmits(['tips'])
+const file = ref(null)
+const media = ref(null)
+const onChange = e => {
+    if (!e.target.files.length) {
+        return
+    }
+
+    let file = e.target.files[0]
+
+    if (checkSizeLimitFree(file.size, 5)) {
+        let reader = new FileReader()
+        reader.onload = function () {
+            images.value.push(base64ToDataURL(reader.result))
+        }
+        reader.readAsDataURL(file)
+    } else {
+        emits('tips','请上传 5MB 以内的 jpg/png 文件')
+    }
+    e.target.value = ''
+}
+
+onMounted(() => {
+
+})
+</script>
+<style lang="scss" scoped>
+.placeholder {
+    cursor: pointer;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    .icon {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        flex-direction: column;
+        color: rgba(255, 255, 255, 0.6);
+        font-size: 14px;
+        span {
+            margin-top: 10px;
+        }
+    }
+    .tips {
+        font-size: 12px;
+        padding: 10px;
+        position: absolute;
+        bottom: 0;
+        width: 100%;
+        color: rgba(255, 255, 255, 0.3);
+    }
+}
+.media {
+    width: 100%;
+    height: 100%;
+    .add {
+        cursor: pointer;
+        font-size: 12px;
+        position: absolute;
+        bottom: 0;
+        left: 0;
+        width: 100%;
+        height: 32px;
+        line-height: 32px;
+        text-align: center;
+        background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), #000 200%);
+        border-radius: 0 0 4px 4px;
+        z-index: 10;
+        &.disable {
+            pointer-events: none;
+        }
+        span {
+            color: #0076f6;
+        }
+    }
+}
+</style>

+ 96 - 0
src/components/form/medias/index.vue

@@ -0,0 +1,96 @@
+<template>
+    <div class="medias">
+        <ul>
+            <li @click="onMediaChange('image')"><span class="checkbox" :class="{ checked: media == 'image' }"></span><i class="iconfont icon-pic"></i></li>
+            <li @click="onMediaChange('video')"><span class="checkbox" :class="{ checked: media == 'video' }"></span><i class="iconfont icon-video"></i></li>
+            <li @click="onMediaChange('audio')"><span class="checkbox" :class="{ checked: media == 'audio' }"></span><i class="iconfont icon-music"></i></li>
+            <li @click="onMediaChange('link')"><span class="checkbox" :class="{ checked: media == 'link' }"></span><i class="iconfont icon-web"></i></li>
+        </ul>
+        <div class="control">
+            <component :is="component" @tips="onTips"></component>
+            <Toast v-if="tips" type="error" :content="tips" :close="() => (tips = null)" />
+        </div>
+    </div>
+</template>
+<script setup>
+import { ref, computed } from 'vue'
+import Toast from '@/components/dialog/Toast'
+import Image from './Image.vue'
+import Video from './Video.vue'
+import Audio from './Audio.vue'
+import Link from './Link.vue'
+const tips = ref(null)
+const media = ref('image')
+const component = computed(() => {
+    switch (media.value) {
+        case 'image':
+            return Image
+        case 'video':
+            return Video
+        case 'audio':
+            return Audio
+        case 'link':
+            return Link
+    }
+})
+const onTips = msg=> {
+    tips.value = msg
+}
+const onMediaChange = type => {
+    media.value = type
+}
+</script>
+<style lang="scss" scoped>
+.medias {
+    ul,
+    li {
+        list-style: none;
+        margin: 0;
+        padding: 0;
+    }
+    ul {
+        display: flex;
+    }
+    li {
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        margin-right: 30px;
+        color: #969595;
+    }
+    .control {
+        background: rgba(255, 255, 255, 0.1);
+        border-radius: 4px 4px 4px 4px;
+        border: 1px solid rgba(255, 255, 255, 0.2);
+        width: 100%;
+        height: 200px;
+        margin: 14px 0;
+        overflow: hidden;
+        position: relative;
+    }
+}
+.checkbox {
+    position: relative;
+    display: block;
+    width: 16px;
+    height: 16px;
+    border-radius: 50%;
+    border: solid 1px #666;
+    margin-right: 6px;
+    background-color: rgba(255, 255, 255, 0.1);
+    &.checked {
+        border-color: #0076f6;
+        &::after {
+            content: '';
+            position: absolute;
+            left: 50%;
+            top: 50%;
+            transform: translate(-50%, -50%);
+            width: 8px;
+            height: 8px;
+            border-radius: 50%;
+            background-color: #0076f6;
+        }
+    }
+}
+</style>

+ 1 - 1
src/pages/SViewer.vue

@@ -39,7 +39,7 @@ import { http } from '@/utils/request'
 import Toast from '@/components/dialog/Toast'
 import browser from '@/utils/browser'
 import Calendar from '@/components/calendar/mobile.vue'
-import sync, { laserChangeMode, loadSourceScene, loadTargetScene, setPanoWithBim } from '@/utils/sync'
+import sync, {  loadSourceScene, loadTargetScene, setPanoWithBim } from '@/utils/sync'
 const isDev = process.env.VUE_APP_TEST == 1
 // 点位信息
 let lastFakeApp = null

+ 29 - 5
src/pages/Viewer.vue

@@ -5,7 +5,7 @@
             <div class="split">
                 <iframe ref="sourceFrame" v-if="sourceURL" :src="sourceURL" frameborder="0" @load="onLoadSource"></iframe>
                 <div class="tools" v-if="source" v-show="!showAdjust && !fscChecked && (dbsChecked || (!target && !bimChecked))">
-                    <div class="item-date">
+                    <!-- <div class="item-date">
                         <calendar
                             name="source"
                             :count="scenes.length"
@@ -17,7 +17,7 @@
                             @prev="onPrevDate"
                             @next="onNextDate"
                         ></calendar>
-                    </div>
+                    </div> -->
                     <div class="item-mode" v-if="source.type == 2">
                         <div class="iconfont icon-show_roaming" :class="{ active: mode == 0 }" @click="onModeChange(0)"></div>
                         <div class="iconfont icon-show_plane" :class="{ active: mode == 1 }" @click="onModeChange(1)"></div>
@@ -70,6 +70,12 @@
                 </div>
             </div>
             <div class="model" v-show="!showAdjust">
+                <div class="file" :class="{ active: fileChecked, disable: fileDisable }" v-show="!fscChecked && !showBim">
+                    <div @click="onFileChecked">
+                        <i class="iconfont icon-note1"></i>
+                        <span>标注</span>
+                    </div>
+                </div>
                 <div class="bim" :class="{ active: bimChecked, disable: bimDisable }" v-show="!fscChecked && !showBim">
                     <div @click="onBimChecked">
                         <i class="iconfont icon-BIM"></i>
@@ -87,19 +93,24 @@
                     <span>全屏</span>
                 </div>
             </div>
+            <TagManager />
         </main>
-        <Toast v-if="showTips" type="warn" :content="showTips" :close="() => (showTips = null)" />
     </article>
+    <Toast v-if="showTips" type="warn" :content="showTips" :close="() => (showTips = null)" />
+    <Files :show="fileChecked" />
 </template>
 
 <script setup>
 import { ref, onMounted, computed, nextTick } from 'vue'
 import { http } from '@/utils/request'
+
 import browser from '@/utils/browser'
 import Toast from '@/components/dialog/Toast'
 import AppHeader from '@/components/header'
 import Calendar from '@/components/calendar'
-import sync, { laserChangeMode, beforeChangeURL, loadSourceScene, loadTargetScene, setPanoWithBim, flyToP1P2 } from '@/utils/sync'
+import Files from '@/components/files'
+import TagManager from '@/components/files/TagManager'
+import sync, {  beforeChangeURL, loadSourceScene, loadTargetScene, setPanoWithBim, flyToP1P2 } from '@/utils/sync'
 const isDev = process.env.VUE_APP_TEST == 1
 // 是否BIM模式
 const showBim = ref(browser.urlHasValue('bim'))
@@ -110,6 +121,7 @@ const showAdjust = ref(browser.urlHasValue('adjust'))
 const bimChecked = ref()
 const dbsChecked = ref(null)
 const fscChecked = ref(null)
+const fileChecked = ref(false)
 
 const datepickName = ref(null)
 
@@ -229,6 +241,10 @@ const targetDays = computed(() => {
     }
 })
 
+const fileDisable = computed(()=>{
+    return false
+})
+
 const bimDisable = computed(() => {
     if (!project.value || !project.value.bimData) {
         return true
@@ -251,7 +267,9 @@ const onLoadSource = () => {
         // BIM单屏模式
         return
     }
-
+    if(source.value.type < 2) {
+        window['kankan'] = sourceFrame.value.contentWindow.app
+    }
     loadSourceScene(sourceFrame, source.value.type < 2 ? 'kankan' : 'laser', mode.value)
 }
 const onLoadTarget = () => {
@@ -383,6 +401,10 @@ const onNextDate = name => {
 }
 
 
+const onFileChecked = () => {
+    fileChecked.value = !fileChecked.value
+}
+
 
 // bim点击
 const onBimChecked = () => {
@@ -595,6 +617,8 @@ main {
         z-index: 1000;
         width: 100%;
         height: 100%;
+        border: none;
+        outline: none;
     }
     .split {
         margin-left: 2px;

+ 3 - 1
src/pages/kankan.js

@@ -2,6 +2,8 @@ import browser from '../utils/browser'
 
 window.app = new KanKan({
     dom: '#app',
-    num: browser.valueFromUrl('m')
+    num: browser.valueFromUrl('m'),
+    isLoadTags:false
 })
+app.use('TagEditor')
 app.render()

+ 2 - 0
src/pages/viewer.js

@@ -2,6 +2,7 @@ import '../assets/scss/theme.scss'
 import '../assets/index.scss'
 import { createApp } from 'vue'
 import { setup } from '../utils/request'
+import ClickOutSide from '../utils/ClickOutSide'
 import App from './Viewer.vue'
 
 Date.prototype.format = function(fmt = 'YYYY-mm-dd HH:MM:SS') {
@@ -38,4 +39,5 @@ String.prototype.toDate = function() {
 
 setup()
 const app = createApp(App)
+app.directive('click-outside', ClickOutSide)
 app.mount('#app')

+ 15 - 0
src/utils/ClickOutSide.js

@@ -0,0 +1,15 @@
+export const clickOutSide = {
+    mounted: function (el, binding, vnode) {
+        el.clickOutsideEvent = function (event) {
+            if (!(el == event.target || el.contains(event.target))) {
+                binding.value(event, el)
+            }
+        }
+        document.addEventListener('click', el.clickOutsideEvent)
+    },
+    unmounted: function (el) {
+        document.removeEventListener('click', el.clickOutsideEvent)
+    },
+}
+
+export default clickOutSide

+ 460 - 0
src/utils/blob.js

@@ -0,0 +1,460 @@
+(function (global, factory) {
+    typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
+    typeof define === 'function' && define.amd ? define(['exports'], factory) :
+    (factory((global.blobUtil = {})));
+}(this, (function (exports) { 'use strict';
+
+    // TODO: including these in blob-util.ts causes typedoc to generate docs for them,
+    // even with --excludePrivate ¯\_(ツ)_/¯
+    /** @private */
+    function loadImage(src, crossOrigin) {
+        return new Promise(function (resolve, reject) {
+            var img = new Image();
+            if (crossOrigin) {
+                img.crossOrigin = crossOrigin;
+            }
+            img.onload = function () {
+                resolve(img);
+            };
+            img.onerror = reject;
+            img.src = src;
+        });
+    }
+    /** @private */
+    function imgToCanvas(img) {
+        var canvas = document.createElement('canvas');
+        canvas.width = img.width;
+        canvas.height = img.height;
+        // copy the image contents to the canvas
+        var context = canvas.getContext('2d');
+        context.drawImage(img, 0, 0, img.width, img.height, 0, 0, img.width, img.height);
+        return canvas;
+    }
+
+    /* global Promise, Image, Blob, FileReader, atob, btoa,
+       BlobBuilder, MSBlobBuilder, MozBlobBuilder, WebKitBlobBuilder, webkitURL */
+    /**
+     * Shim for
+     * [`new Blob()`](https://developer.mozilla.org/en-US/docs/Web/API/Blob.Blob)
+     * to support
+     * [older browsers that use the deprecated `BlobBuilder` API](http://caniuse.com/blob).
+     *
+     * Example:
+     *
+     * ```js
+     * var myBlob = blobUtil.createBlob(['hello world'], {type: 'text/plain'});
+     * ```
+     *
+     * @param parts - content of the Blob
+     * @param properties - usually `{type: myContentType}`,
+     *                           you can also pass a string for the content type
+     * @returns Blob
+     */
+    function createBlob(parts, properties) {
+        parts = parts || [];
+        properties = properties || {};
+        if (typeof properties === 'string') {
+            properties = { type: properties }; // infer content type
+        }
+        try {
+            return new Blob(parts, properties);
+        }
+        catch (e) {
+            if (e.name !== 'TypeError') {
+                throw e;
+            }
+            var Builder = typeof BlobBuilder !== 'undefined'
+                ? BlobBuilder : typeof MSBlobBuilder !== 'undefined'
+                ? MSBlobBuilder : typeof MozBlobBuilder !== 'undefined'
+                ? MozBlobBuilder : WebKitBlobBuilder;
+            var builder = new Builder();
+            for (var i = 0; i < parts.length; i += 1) {
+                builder.append(parts[i]);
+            }
+            return builder.getBlob(properties.type);
+        }
+    }
+    /**
+     * Shim for
+     * [`URL.createObjectURL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL.createObjectURL)
+     * to support browsers that only have the prefixed
+     * `webkitURL` (e.g. Android <4.4).
+     *
+     * Example:
+     *
+     * ```js
+     * var myUrl = blobUtil.createObjectURL(blob);
+     * ```
+     *
+     * @param blob
+     * @returns url
+     */
+    function createObjectURL(blob) {
+        return (typeof URL !== 'undefined' ? URL : webkitURL).createObjectURL(blob);
+    }
+    /**
+     * Shim for
+     * [`URL.revokeObjectURL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL.revokeObjectURL)
+     * to support browsers that only have the prefixed
+     * `webkitURL` (e.g. Android <4.4).
+     *
+     * Example:
+     *
+     * ```js
+     * blobUtil.revokeObjectURL(myUrl);
+     * ```
+     *
+     * @param url
+     */
+    function revokeObjectURL(url) {
+        return (typeof URL !== 'undefined' ? URL : webkitURL).revokeObjectURL(url);
+    }
+    /**
+     * Convert a `Blob` to a binary string.
+     *
+     * Example:
+     *
+     * ```js
+     * blobUtil.blobToBinaryString(blob).then(function (binaryString) {
+     *   // success
+     * }).catch(function (err) {
+     *   // error
+     * });
+     * ```
+     *
+     * @param blob
+     * @returns Promise that resolves with the binary string
+     */
+    function blobToBinaryString(blob) {
+        return new Promise(function (resolve, reject) {
+            var reader = new FileReader();
+            var hasBinaryString = typeof reader.readAsBinaryString === 'function';
+            reader.onloadend = function () {
+                var result = reader.result || '';
+                if (hasBinaryString) {
+                    return resolve(result);
+                }
+                resolve(arrayBufferToBinaryString(result));
+            };
+            reader.onerror = reject;
+            if (hasBinaryString) {
+                reader.readAsBinaryString(blob);
+            }
+            else {
+                reader.readAsArrayBuffer(blob);
+            }
+        });
+    }
+    /**
+     * Convert a base64-encoded string to a `Blob`.
+     *
+     * Example:
+     *
+     * ```js
+     * var blob = blobUtil.base64StringToBlob(base64String);
+     * ```
+     * @param base64 - base64-encoded string
+     * @param type - the content type (optional)
+     * @returns Blob
+     */
+    function base64StringToBlob(base64, type) {
+        var parts = [binaryStringToArrayBuffer(atob(base64))];
+        return type ? createBlob(parts, { type: type }) : createBlob(parts);
+    }
+    /**
+     * Convert a binary string to a `Blob`.
+     *
+     * Example:
+     *
+     * ```js
+     * var blob = blobUtil.binaryStringToBlob(binaryString);
+     * ```
+     *
+     * @param binary - binary string
+     * @param type - the content type (optional)
+     * @returns Blob
+     */
+    function binaryStringToBlob(binary, type) {
+        return base64StringToBlob(btoa(binary), type);
+    }
+    /**
+     * Convert a `Blob` to a binary string.
+     *
+     * Example:
+     *
+     * ```js
+     * blobUtil.blobToBase64String(blob).then(function (base64String) {
+     *   // success
+     * }).catch(function (err) {
+     *   // error
+     * });
+     * ```
+     *
+     * @param blob
+     * @returns Promise that resolves with the binary string
+     */
+    function blobToBase64String(blob) {
+        return blobToBinaryString(blob).then(btoa);
+    }
+    /**
+     * Convert a data URL string
+     * (e.g. `'data:image/png;base64,iVBORw0KG...'`)
+     * to a `Blob`.
+     *
+     * Example:
+     *
+     * ```js
+     * var blob = blobUtil.dataURLToBlob(dataURL);
+     * ```
+     *
+     * @param dataURL - dataURL-encoded string
+     * @returns Blob
+     */
+    function dataURLToBlob(dataURL) {
+        var type = dataURL.match(/data:([^;]+)/)[1];
+        var base64 = dataURL.replace(/^[^,]+,/, '');
+        var buff = binaryStringToArrayBuffer(atob(base64));
+        return createBlob([buff], { type: type });
+    }
+    /**
+     * Convert a `Blob` to a data URL string
+     * (e.g. `'data:image/png;base64,iVBORw0KG...'`).
+     *
+     * Example:
+     *
+     * ```js
+     * var dataURL = blobUtil.blobToDataURL(blob);
+     * ```
+     *
+     * @param blob
+     * @returns Promise that resolves with the data URL string
+     */
+    function blobToDataURL(blob) {
+        return blobToBase64String(blob).then(function (base64String) {
+            return 'data:' + blob.type + ';base64,' + base64String;
+        });
+    }
+    /**
+     * Convert an image's `src` URL to a data URL by loading the image and painting
+     * it to a `canvas`.
+     *
+     * Note: this will coerce the image to the desired content type, and it
+     * will only paint the first frame of an animated GIF.
+     *
+     * Examples:
+     *
+     * ```js
+     * blobUtil.imgSrcToDataURL('http://mysite.com/img.png').then(function (dataURL) {
+     *   // success
+     * }).catch(function (err) {
+     *   // error
+     * });
+     * ```
+     *
+     * ```js
+     * blobUtil.imgSrcToDataURL('http://some-other-site.com/img.jpg', 'image/jpeg',
+     *                          'Anonymous', 1.0).then(function (dataURL) {
+     *   // success
+     * }).catch(function (err) {
+     *   // error
+     * });
+     * ```
+     *
+     * @param src - image src
+     * @param type - the content type (optional, defaults to 'image/png')
+     * @param crossOrigin - for CORS-enabled images, set this to
+     *                                         'Anonymous' to avoid "tainted canvas" errors
+     * @param quality - a number between 0 and 1 indicating image quality
+     *                                     if the requested type is 'image/jpeg' or 'image/webp'
+     * @returns Promise that resolves with the data URL string
+     */
+    function imgSrcToDataURL(src, type, crossOrigin, quality) {
+        type = type || 'image/png';
+        return loadImage(src, crossOrigin).then(imgToCanvas).then(function (canvas) {
+            return canvas.toDataURL(type, quality);
+        });
+    }
+    /**
+     * Convert a `canvas` to a `Blob`.
+     *
+     * Examples:
+     *
+     * ```js
+     * blobUtil.canvasToBlob(canvas).then(function (blob) {
+     *   // success
+     * }).catch(function (err) {
+     *   // error
+     * });
+     * ```
+     *
+     * Most browsers support converting a canvas to both `'image/png'` and `'image/jpeg'`. You may
+     * also want to try `'image/webp'`, which will work in some browsers like Chrome (and in other browsers, will just fall back to `'image/png'`):
+     *
+     * ```js
+     * blobUtil.canvasToBlob(canvas, 'image/webp').then(function (blob) {
+     *   // success
+     * }).catch(function (err) {
+     *   // error
+     * });
+     * ```
+     *
+     * @param canvas - HTMLCanvasElement
+     * @param type - the content type (optional, defaults to 'image/png')
+     * @param quality - a number between 0 and 1 indicating image quality
+     *                                     if the requested type is 'image/jpeg' or 'image/webp'
+     * @returns Promise that resolves with the `Blob`
+     */
+    function canvasToBlob(canvas, type, quality) {
+        if (typeof canvas.toBlob === 'function') {
+            return new Promise(function (resolve) {
+                canvas.toBlob(resolve, type, quality);
+            });
+        }
+        return Promise.resolve(dataURLToBlob(canvas.toDataURL(type, quality)));
+    }
+    /**
+     * Convert an image's `src` URL to a `Blob` by loading the image and painting
+     * it to a `canvas`.
+     *
+     * Note: this will coerce the image to the desired content type, and it
+     * will only paint the first frame of an animated GIF.
+     *
+     * Examples:
+     *
+     * ```js
+     * blobUtil.imgSrcToBlob('http://mysite.com/img.png').then(function (blob) {
+     *   // success
+     * }).catch(function (err) {
+     *   // error
+     * });
+     * ```
+     *
+     * ```js
+     * blobUtil.imgSrcToBlob('http://some-other-site.com/img.jpg', 'image/jpeg',
+     *                          'Anonymous', 1.0).then(function (blob) {
+     *   // success
+     * }).catch(function (err) {
+     *   // error
+     * });
+     * ```
+     *
+     * @param src - image src
+     * @param type - the content type (optional, defaults to 'image/png')
+     * @param crossOrigin - for CORS-enabled images, set this to
+     *                                         'Anonymous' to avoid "tainted canvas" errors
+     * @param quality - a number between 0 and 1 indicating image quality
+     *                                     if the requested type is 'image/jpeg' or 'image/webp'
+     * @returns Promise that resolves with the `Blob`
+     */
+    function imgSrcToBlob(src, type, crossOrigin, quality) {
+        type = type || 'image/png';
+        return loadImage(src, crossOrigin).then(imgToCanvas).then(function (canvas) {
+            return canvasToBlob(canvas, type, quality);
+        });
+    }
+    /**
+     * Convert an `ArrayBuffer` to a `Blob`.
+     *
+     * Example:
+     *
+     * ```js
+     * var blob = blobUtil.arrayBufferToBlob(arrayBuff, 'audio/mpeg');
+     * ```
+     *
+     * @param buffer
+     * @param type - the content type (optional)
+     * @returns Blob
+     */
+    function arrayBufferToBlob(buffer, type) {
+        return createBlob([buffer], type);
+    }
+    /**
+     * Convert a `Blob` to an `ArrayBuffer`.
+     *
+     * Example:
+     *
+     * ```js
+     * blobUtil.blobToArrayBuffer(blob).then(function (arrayBuff) {
+     *   // success
+     * }).catch(function (err) {
+     *   // error
+     * });
+     * ```
+     *
+     * @param blob
+     * @returns Promise that resolves with the `ArrayBuffer`
+     */
+    function blobToArrayBuffer(blob) {
+        return new Promise(function (resolve, reject) {
+            var reader = new FileReader();
+            reader.onloadend = function () {
+                var result = reader.result || new ArrayBuffer(0);
+                resolve(result);
+            };
+            reader.onerror = reject;
+            reader.readAsArrayBuffer(blob);
+        });
+    }
+    /**
+     * Convert an `ArrayBuffer` to a binary string.
+     *
+     * Example:
+     *
+     * ```js
+     * var myString = blobUtil.arrayBufferToBinaryString(arrayBuff)
+     * ```
+     *
+     * @param buffer - array buffer
+     * @returns binary string
+     */
+    function arrayBufferToBinaryString(buffer) {
+        var binary = '';
+        var bytes = new Uint8Array(buffer);
+        var length = bytes.byteLength;
+        var i = -1;
+        while (++i < length) {
+            binary += String.fromCharCode(bytes[i]);
+        }
+        return binary;
+    }
+    /**
+     * Convert a binary string to an `ArrayBuffer`.
+     *
+     * ```js
+     * var myBuffer = blobUtil.binaryStringToArrayBuffer(binaryString)
+     * ```
+     *
+     * @param binary - binary string
+     * @returns array buffer
+     */
+    function binaryStringToArrayBuffer(binary) {
+        var length = binary.length;
+        var buf = new ArrayBuffer(length);
+        var arr = new Uint8Array(buf);
+        var i = -1;
+        while (++i < length) {
+            arr[i] = binary.charCodeAt(i);
+        }
+        return buf;
+    }
+
+    exports.createBlob = createBlob;
+    exports.createObjectURL = createObjectURL;
+    exports.revokeObjectURL = revokeObjectURL;
+    exports.blobToBinaryString = blobToBinaryString;
+    exports.base64StringToBlob = base64StringToBlob;
+    exports.binaryStringToBlob = binaryStringToBlob;
+    exports.blobToBase64String = blobToBase64String;
+    exports.dataURLToBlob = dataURLToBlob;
+    exports.blobToDataURL = blobToDataURL;
+    exports.imgSrcToDataURL = imgSrcToDataURL;
+    exports.canvasToBlob = canvasToBlob;
+    exports.imgSrcToBlob = imgSrcToBlob;
+    exports.arrayBufferToBlob = arrayBufferToBlob;
+    exports.blobToArrayBuffer = blobToArrayBuffer;
+    exports.arrayBufferToBinaryString = arrayBufferToBinaryString;
+    exports.binaryStringToArrayBuffer = binaryStringToArrayBuffer;
+
+    Object.defineProperty(exports, '__esModule', { value: true });
+
+})));

+ 0 - 1
src/utils/file.js

@@ -57,7 +57,6 @@ export const checkSizeLimit = (type, size) => {
 
 export const checkSizeLimitFree = (size, limit) => {
     size = size / 1024 / 1024
-
     return size <= limit
 }
 

+ 1 - 1
vue.config.js

@@ -20,7 +20,7 @@ const devServer = {
             changeOrigin: true
         },
         '/smart-site': {
-            target: 'https://test.4dkankan.com/',
+            target: 'http://192.168.0.152:8111', //'https://test.4dkankan.com/',
             changeOrigin: true
         }
     }