Browse Source

fix: `Merge branch 'dev' of http://face3d.4dage.com:7005/4dkankan/4dkankan_bim into dev

bill 2 years ago
parent
commit
a09dd61370
68 changed files with 34177 additions and 122 deletions
  1. 12731 0
      package-lock.json
  2. 6 2
      package.json
  3. 173 0
      public/__langs/css/index.css
  4. 126 0
      public/__langs/index.html
  5. 105 0
      public/__langs/locales/en.json
  6. 105 0
      public/__langs/locales/ja.json
  7. 105 0
      public/__langs/locales/zh.json
  8. 34 0
      public/__langs/script/saveLange.js
  9. 4 0
      public/smart-kankan.html
  10. 3 1
      public/smart-viewer.html
  11. 11 0
      public/static/ext/animate/animate.min.css
  12. 1 0
      public/static/ext/base64.min.js
  13. 539 0
      public/static/ext/iconfont/demo.css
  14. 3385 0
      public/static/ext/iconfont/demo_index.html
  15. 571 0
      public/static/ext/iconfont/iconfont.css
  16. 1 0
      public/static/ext/iconfont/iconfont.js
  17. 982 0
      public/static/ext/iconfont/iconfont.json
  18. BIN
      public/static/ext/iconfont/iconfont.ttf
  19. BIN
      public/static/ext/iconfont/iconfont.woff
  20. BIN
      public/static/ext/iconfont/iconfont.woff2
  21. 16 0
      public/static/ext/mobile-detect.js
  22. 10663 0
      public/static/ext/swiper/swiper.js
  23. 13 0
      public/static/ext/swiper/swiper.min.css
  24. 14 0
      public/static/ext/swiper/swiper.min.js
  25. 52 0
      scripts/update-i18n.js
  26. BIN
      src/assets/img/pic_bg.png
  27. 19 0
      src/assets/index.scss
  28. 4 4
      src/assets/scss/_base-vars.scss
  29. 2 2
      src/assets/scss/components/_button.scss
  30. 116 0
      src/components/audio/index.vue
  31. 47 0
      src/components/button/index.vue
  32. 24 13
      src/components/calendar/mobile.vue
  33. 1 1
      src/components/datepicker/PickerDay.vue
  34. 14 5
      src/components/dialog/Confirm.vue
  35. 324 0
      src/components/files/TagEditor.vue
  36. 149 0
      src/components/files/TagItem.vue
  37. 52 0
      src/components/files/TagManager.vue
  38. 335 0
      src/components/files/TagView.vue
  39. 452 0
      src/components/files/content/Comment.vue
  40. 276 0
      src/components/files/content/TagMsg.vue
  41. 458 0
      src/components/files/index.vue
  42. 72 0
      src/components/form/Area.vue
  43. 171 0
      src/components/form/Input.vue
  44. 197 0
      src/components/form/SelectList.vue
  45. 133 0
      src/components/form/medias/Audio.vue
  46. 197 0
      src/components/form/medias/Image.vue
  47. 140 0
      src/components/form/medias/Link.vue
  48. 135 0
      src/components/form/medias/Video.vue
  49. 111 0
      src/components/form/medias/index.vue
  50. 4 4
      src/components/header/CopyLink.vue
  51. 13 9
      src/components/header/Login.vue
  52. 54 30
      src/components/header/index.vue
  53. 68 0
      src/i18n/index.js
  54. 105 0
      src/locales/en.json
  55. 105 0
      src/locales/ja.json
  56. 105 0
      src/locales/zh.json
  57. 12 10
      src/pages/SViewer.vue
  58. 98 32
      src/pages/Viewer.vue
  59. 6 1
      src/pages/kankan.js
  60. 12 2
      src/pages/sviewer.js
  61. 19 1
      src/pages/viewer.js
  62. 18 0
      src/utils/ClickOutSide.js
  63. 9 2
      src/utils/ConvertViews.js
  64. 460 0
      src/utils/blob.js
  65. 14 0
      src/utils/browser.js
  66. 0 1
      src/utils/file.js
  67. 5 1
      src/utils/request.js
  68. 1 1
      vue.config.js

File diff suppressed because it is too large
+ 12731 - 0
package-lock.json


+ 6 - 2
package.json

@@ -1,16 +1,20 @@
 {
   "name": "@kankan/smart-bim",
   "private": true,
-  "version": "1.0.0",
+  "version": "4.9.1",
   "scripts": {
     "serve": "vue-cli-service serve",
     "build": "vue-cli-service build",
-    "build:test": "vue-cli-service build --mode test"
+    "build:test": "vue-cli-service build --mode test",
+    "langs": "node ./scripts/update-i18n.js"
   },
   "dependencies": {
     "axios": "^0.21.1",
     "clipboard": "^2.0.11",
     "core-js": "^3.6.5",
+    "cors": "^2.8.5",
+    "express": "^4.18.2",
+    "quill": "^1.3.6",
     "vant": "^3.6.4",
     "vue": "^3.2.26",
     "vue-i18n": "9",

+ 173 - 0
public/__langs/css/index.css

@@ -0,0 +1,173 @@
+.locales-setting {
+  position: fixed;
+  z-index: 20000;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.5);
+  pointer-events: all;
+}
+.locales-setting > div {
+  padding: 40px 20px;
+  display: flex;
+  align-items: flex-start;
+  position: absolute;
+  left: 30px;
+  top: 30px;
+  bottom: 30px;
+  right: 30px;
+  background: #efefef;
+  box-shadow: 0 0 8px #666;
+}
+.locales-setting > div aside {
+  width: 200px;
+  height: 100%;
+  border-right: solid 1px #999;
+}
+.locales-setting > div aside li {
+  margin-bottom: 10px;
+  cursor: pointer;
+}
+.locales-setting > div aside li.active {
+  color: #f60;
+}
+.locales-setting > div main {
+  flex: 1;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  overflow-y: auto;
+}
+.locales-setting > div main li {
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+}
+.locales-setting > div main li input {
+  height: 24px;
+  border: solid 1px #666;
+  width: 100%;
+  padding: 0 4px;
+}
+.locales-setting > div main li > div:first-child {
+  width: 400px;
+  /* text-align: right; */
+  padding-right: 3px;
+}
+.locales-setting > div main li > div:last-child {
+  width: 100%;
+}
+.locales-setting .save {
+  position: absolute;
+  right: 5px;
+  top: 5px;
+}
+.locales-setting .save select {
+  border: solid 1px #666;
+  width: 70px;
+  height: 24px;
+  text-align: center;
+  background: #fff;
+}
+.locales-setting .save button {
+  border: solid 1px #666;
+  width: 70px;
+  height: 24px;
+  text-align: center;
+  background: #fff;
+  margin-left: 10px;
+}
+
+
+
+
+
+.loader {
+  position: relative;
+  margin: 0 auto;
+  width: 100px
+
+}
+.loader:before {
+  content: '';
+  display: block;
+  padding-top: 100%;
+}
+
+.circular {
+  animation: rotate 2s linear infinite;
+  height: 100%;
+  transform-origin: center center;
+  width: 100%;
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  margin: auto;
+}
+
+.path {
+  stroke-dasharray: 1, 200;
+  stroke-dashoffset: 0;
+  animation: dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite;
+  stroke-linecap: round;
+}
+
+@keyframes rotate {
+  100% {
+    transform: rotate(360deg);
+  }
+}
+
+@keyframes dash {
+  0% {
+    stroke-dasharray: 1, 200;
+    stroke-dashoffset: 0;
+  }
+  50% {
+    stroke-dasharray: 89, 200;
+    stroke-dashoffset: -35px;
+  }
+  100% {
+    stroke-dasharray: 89, 200;
+    stroke-dashoffset: -124px;
+  }
+}
+
+@keyframes color {
+  100%,
+  0% {
+    stroke: #d62d20;
+  }
+  40% {
+    stroke: #0057e7;
+  }
+  66% {
+    stroke: #008744;
+  }
+  80%,
+  90% {
+    stroke: #ffa700;
+  }
+}
+
+
+body {
+  background-color: #eee;
+}
+.showbox {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  padding: 5%;
+  z-index: 100000;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+

+ 126 - 0
public/__langs/index.html

@@ -0,0 +1,126 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="UTF-8" />
+        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+        <title>配置国际化</title>
+    </head>
+    <link rel="stylesheet" href="./css/index.css" />
+    <body>
+        <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
+        <div id="app">
+            <div class="locales-setting">
+                <div>
+                    <div class="save">
+                        <select v-model="locale" @change="onLocaleChange">
+                            <option value="zh">中文</option>
+                            <option value="en">英文</option>
+                            <option value="kr">韩文</option>
+                            <option value="fr">法语</option>
+                            <option value="ja">日语</option>
+                        </select>
+                        <button @click="onSave">保存</button>
+                    </div>
+
+                    <aside>
+                        <ul>
+                            <li v-for="menu in menus" @click="onModuleChange(menu.name)" :class="{ active: menu.name == module }">{{ menu.text }}</li>
+                        </ul>
+                    </aside>
+                    <main>
+                        <ul>
+                            <li v-for="locale in locales">
+                                <div>{{ locale.key }}:</div>
+                                <div><input type="text" @input="onChangeVal(locale)" v-model="locale.value" /></div>
+                            </li>
+                        </ul>
+                    </main>
+                </div>
+            </div>
+            <div class="showbox" v-if="loading">
+                <div class="loader">
+                    <svg class="circular" viewBox="25 25 50 50">
+                        <circle class="path" cx="50" cy="50" r="20" fill="none" stroke-width="2" stroke-miterlimit="10" />
+                    </svg>
+                </div>
+            </div>
+        </div>
+
+        <script>
+            const { createApp } = Vue
+            createApp({
+                data() {
+                    return {
+                        locale: 'zh',
+                        locales: [],
+                        info: {},
+                        menus: [],
+                        module: 'home',
+                        respone: null,
+                        loading: false,
+                    }
+                },
+                methods: {
+                    async onSave() {
+                        this.loading = true
+
+                        let params = this.respone
+                        fetch('http://192.168.0.130:9091/save', {
+                            method: 'post',
+                            headers: {
+                                'Content-Type': 'application/x-www-form-urlencoded',
+                            },
+                            body: `q=${JSON.stringify(params)}&locale=${this.locale}`,
+                        })
+                            .then(async res => {
+                                this.loading = false
+
+                                let json = await res.json()
+                                alert('保存成功')
+                            })
+                            .catch(err => {
+                                this.loading = false
+                                alert('保存失败,请联系管理员')
+                            })
+                    },
+                    onChangeVal(item) {
+                        this.respone[this.module][item.key] = item.value
+                    },
+                    async onLocaleChange() {
+                        this.locales = []
+                        this.respone = await this.fetchLocale()
+                        this.initData(this.respone)
+                    },
+                    onModuleChange(name) {
+                        this.locales = []
+                        this.module = name
+                        this.initData(this.respone)
+                    },
+                    initData(data, init = false) {
+                        for (let key in data) {
+                            if (key.split('.').pop() == 'name') {
+                                if (init) {
+                                    this.menus.push({ name: key.split('.')[0], text: data[key] })
+                                }
+                            } else if (typeof data[key] == 'object' && key == this.module) {
+                                this.initData(data[key])
+                            } else if (typeof data[key] == 'string') {
+                                this.locales.push({ key: key.split('.')[0], value: data[key] })
+                            }
+                        }
+                    },
+                    async fetchLocale(locale) {
+                        return await (await fetch(`./locales/${this.locale}.json?${Date.now()}`)).json()
+                    },
+                },
+                mounted() {
+                    this.fetchLocale('zh').then(res => {
+                        this.respone = res
+                        this.initData(res, true)
+                    })
+                },
+            }).mount('#app')
+        </script>
+    </body>
+</html>

+ 105 - 0
public/__langs/locales/en.json

@@ -0,0 +1,105 @@
+{
+    "home": {
+        "thickness": "密度选择",
+        "dateScene": "选择日期未上传场景",
+        "notChoosePoint": "您还未选择关联位置",
+        "notFindFile": "未发现BIM文件",
+        "notFindScene": "未发现对比场景",
+        "sceneDelete": "当前场景已被删除,无法进行查看",
+        "tag": "tag",
+        "splitScreen": "分屏",
+        "fullScreen": "全屏"
+    },
+    "home.name": "首页",
+    "header": {
+        "passwordText1": "密码不能为空",
+        "phoneText1": "手机号码不能为空",
+        "phoneText2": "请输入正确手机号",
+        "copyLink": "复制链接",
+        "shareLink": "分享链接",
+        "setting": "设为",
+        "reset": "重设",
+        "userInfo": "个人信息",
+        "loginout": "退出登录",
+        "adhustText1": "为场景设置关联位置",
+        "adhustText2": "请选择位置,确认左右视图中的场景在同一位置后,单击右侧按钮将其设为关联位置。",
+        "userLogin": "用户登录",
+        "rememberPassword": "记住密码",
+        "forgetPassword": "忘记密码",
+        "resigter": "官网注册",
+        "setPointfaidText": "匹配失败,请选择不同点位进行同步",
+        "pointUpdate": "关联位置已更新"
+    },
+    "components": {
+        "uploadVideo": "上传视频",
+        "limitFileSizeBit": "支持 {file} 文件:≤ {size}MB,≤ {bit}Mbps",
+        "linkView": "网页展示区",
+        "continueAdd": "继续添加",
+        "uploadImg": "上传图片",
+        "limitImgLength": "支持JPG、PNG等图片格式,单张不超过5MB,最多支持上传9张。",
+        "TipsImgLength": "图片数量最多支持上传9张",
+        "limitFileSize": "支持 {file} 文件:≤ {size}MB",
+        "FileSizeTips": "请上传 {size}MB 以内的 {file} 文件",
+        "uploadAudio": "上传音频",
+        "year": "年",
+        "month": "月",
+        "day": "日",
+        "chooseTime": "选择时间"
+    },
+    "tag": {
+        "addComment": "发一条评论吧",
+        "addCommentTips": "请输入内容",
+        "deletetCommentTips": "确定要删除吗?",
+        "noComment": "暂无评论",
+        "unkownUser": "未知用户",
+        "reply": "reply",
+        "comment": "comment",
+        "creater": "创建人",
+        "createTime": "创建时间",
+        "statusText1": "待处理",
+        "statusText2": "进行中",
+        "statusText3": "未解决",
+        "statusText4": "已解决",
+        "uploadFile": "上传附件",
+        "desc": "描述",
+        "member": "涉及的成员",
+        "status": "状态",
+        "inputDesc": "请输入描述",
+        "inputMember": "请选择需要通知的项目人员",
+        "inputStatus": "请选择处理状态",
+        "inputTagName": "请输入标注名称",
+        "tagName": "标注名称",
+        "creatTag": "新建标注",
+        "addTag": "添加标注",
+        "isAddTag": "已添加标注",
+        "deleteTagText": "确定要删除资料吗?"
+    },
+    "tag.name": "标注",
+    "components.name": "组件",
+    "header.name": "头部",
+    "common": {
+        "high": "高",
+        "middle": "中",
+        "low": "低",
+        "input": "请输入",
+        "publish": "发布",
+        "submit": "提交",
+        "exit": "退出",
+        "delete": "删除",
+        "edit": "编辑",
+        "confirm": "confirm",
+        "tips": "tips",
+        "login": "login",
+        "cancel": "cancel",
+        "sync": "sync",
+        "deleteSuccess": "删除成功",
+        "copySuccess": "复制成功",
+        "syncSuccess": "同步成功"
+    },
+    "common.name": "通用",
+    "code": {
+        "4008": "用户未登录",
+        "failed": "连接服务器失败"
+    },
+    "code.name": "状态码"
+}

+ 105 - 0
public/__langs/locales/ja.json

@@ -0,0 +1,105 @@
+{
+    "home": {
+        "thickness": "密度选择",
+        "dateScene": "选择日期未上传场景",
+        "notChoosePoint": "您还未选择关联位置",
+        "notFindFile": "未发现BIM文件",
+        "notFindScene": "未发现对比场景",
+        "sceneDelete": "当前场景已被删除,无法进行查看",
+        "tag": "标注",
+        "splitScreen": "分屏",
+        "fullScreen": "全屏"
+    },
+    "home.name": "首页",
+    "header": {
+        "passwordText1": "密码不能为空",
+        "phoneText1": "手机号码不能为空",
+        "phoneText2": "请输入正确手机号",
+        "copyLink": "复制链接",
+        "shareLink": "分享链接",
+        "setting": "设为",
+        "reset": "重设",
+        "userInfo": "个人信息",
+        "loginout": "退出登录",
+        "adhustText1": "为场景设置关联位置",
+        "adhustText2": "请选择位置,确认左右视图中的场景在同一位置后,单击右侧按钮将其设为关联位置。",
+        "userLogin": "用户登录",
+        "rememberPassword": "记住密码",
+        "forgetPassword": "忘记密码",
+        "resigter": "官网注册",
+        "setPointfaidText": "匹配失败,请选择不同点位进行同步",
+        "pointUpdate": "关联位置已更新"
+    },
+    "components": {
+        "uploadVideo": "上传视频",
+        "limitFileSizeBit": "支持 {file} 文件:≤ {size}MB,≤ {bit}Mbps",
+        "linkView": "网页展示区",
+        "continueAdd": "继续添加",
+        "uploadImg": "上传图片",
+        "limitImgLength": "支持JPG、PNG等图片格式,单张不超过5MB,最多支持上传9张。",
+        "TipsImgLength": "图片数量最多支持上传9张",
+        "limitFileSize": "支持 {file} 文件:≤ {size}MB",
+        "FileSizeTips": "请上传 {size}MB 以内的 {file} 文件",
+        "uploadAudio": "上传音频",
+        "year": "年",
+        "month": "月",
+        "day": "日",
+        "chooseTime": "选择时间"
+    },
+    "tag": {
+        "addComment": "发一条评论吧",
+        "addCommentTips": "请输入内容",
+        "deletetCommentTips": "确定要删除吗?",
+        "noComment": "暂无评论",
+        "unkownUser": "未知用户",
+        "reply": "回复",
+        "comment": "评论",
+        "creater": "创建人",
+        "createTime": "创建时间",
+        "statusText1": "待处理",
+        "statusText2": "进行中",
+        "statusText3": "未解决",
+        "statusText4": "已解决",
+        "uploadFile": "上传附件",
+        "desc": "描述",
+        "member": "涉及的成员",
+        "status": "状态",
+        "inputDesc": "请输入描述",
+        "inputMember": "请选择需要通知的项目人员",
+        "inputStatus": "请选择处理状态",
+        "inputTagName": "请输入标注名称",
+        "tagName": "标注名称",
+        "creatTag": "新建标注",
+        "addTag": "添加标注",
+        "isAddTag": "已添加标注",
+        "deleteTagText": "确定要删除资料吗?"
+    },
+    "tag.name": "标注",
+    "components.name": "组件",
+    "header.name": "头部",
+    "common": {
+        "high": "高",
+        "middle": "中",
+        "low": "低",
+        "input": "请输入",
+        "publish": "发布",
+        "submit": "提交",
+        "exit": "退出",
+        "delete": "删除",
+        "edit": "编辑",
+        "confirm": "确定",
+        "tips": "提示",
+        "login": "登录",
+        "cancel": "取消",
+        "sync": "同步",
+        "deleteSuccess": "删除成功",
+        "copySuccess": "复制成功",
+        "syncSuccess": "同步成功"
+    },
+    "common.name": "通用",
+    "code": {
+        "4008": "用户未登录",
+        "failed": "连接服务器失败"
+    },
+    "code.name": "状态码"
+}

+ 105 - 0
public/__langs/locales/zh.json

@@ -0,0 +1,105 @@
+{
+    "home": {
+        "thickness": "密度选择",
+        "dateScene": "选择日期未上传场景",
+        "notChoosePoint": "您还未选择关联位置",
+        "notFindFile": "未发现BIM文件",
+        "notFindScene": "未发现对比场景",
+        "sceneDelete": "当前场景已被删除,无法进行查看",
+        "tag": "标注",
+        "splitScreen": "分屏",
+        "fullScreen": "全屏"
+    },
+    "home.name": "首页",
+    "header": {
+        "passwordText1": "密码不能为空",
+        "phoneText1": "手机号码不能为空",
+        "phoneText2": "请输入正确手机号",
+        "copyLink": "复制链接",
+        "shareLink": "分享链接",
+        "setting": "设为",
+        "reset": "重设",
+        "userInfo": "个人信息",
+        "loginout": "退出登录",
+        "adhustText1": "为场景设置关联位置",
+        "adhustText2": "请选择位置,确认左右视图中的场景在同一位置后,单击右侧按钮将其设为关联位置。",
+        "userLogin": "用户登录",
+        "rememberPassword": "记住密码",
+        "forgetPassword": "忘记密码",
+        "resigter": "官网注册",
+        "setPointfaidText": "匹配失败,请选择不同点位进行同步",
+        "pointUpdate": "关联位置已更新"
+    },
+    "components": {
+        "uploadVideo": "上传视频",
+        "limitFileSizeBit": "支持 {file} 文件:≤ {size}MB,≤ {bit}Mbps",
+        "linkView": "网页展示区",
+        "continueAdd": "继续添加",
+        "uploadImg": "上传图片",
+        "limitImgLength": "支持JPG、PNG等图片格式,单张不超过5MB,最多支持上传9张。",
+        "TipsImgLength": "图片数量最多支持上传9张",
+        "limitFileSize": "支持 {file} 文件:≤ {size}MB",
+        "FileSizeTips": "请上传 {size}MB 以内的 {file} 文件",
+        "uploadAudio": "上传音频",
+        "year": "年",
+        "month": "月",
+        "day": "日",
+        "chooseTime": "选择时间"
+    },
+    "tag": {
+        "addComment": "发一条评论吧",
+        "addCommentTips": "请输入内容",
+        "deletetCommentTips": "确定要删除吗?",
+        "noComment": "暂无评论",
+        "unkownUser": "未知用户",
+        "reply": "回复",
+        "comment": "评论",
+        "creater": "创建人",
+        "createTime": "创建时间",
+        "statusText1": "待处理",
+        "statusText2": "进行中",
+        "statusText3": "未解决",
+        "statusText4": "已解决",
+        "uploadFile": "上传附件",
+        "desc": "描述",
+        "member": "涉及的成员",
+        "status": "状态",
+        "inputDesc": "请输入描述",
+        "inputMember": "请选择需要通知的项目人员",
+        "inputStatus": "请选择处理状态",
+        "inputTagName": "请输入标注名称",
+        "tagName": "标注名称",
+        "creatTag": "新建标注",
+        "addTag": "添加标注",
+        "isAddTag": "已添加标注",
+        "deleteTagText": "确定要删除资料吗?"
+    },
+    "tag.name": "标注",
+    "components.name": "组件",
+    "header.name": "头部",
+    "common": {
+        "high": "高",
+        "middle": "中",
+        "low": "低",
+        "input": "请输入",
+        "publish": "发布",
+        "submit": "提交",
+        "exit": "退出",
+        "delete": "删除",
+        "edit": "编辑",
+        "confirm": "确定",
+        "tips": "提示",
+        "login": "登录",
+        "cancel": "取消",
+        "sync": "同步",
+        "deleteSuccess": "删除成功",
+        "copySuccess": "复制成功",
+        "syncSuccess": "同步成功"
+    },
+    "common.name": "通用",
+    "code": {
+        "4008": "用户未登录",
+        "failed": "连接服务器失败"
+    },
+    "code.name": "状态码"
+}

+ 34 - 0
public/__langs/script/saveLange.js

@@ -0,0 +1,34 @@
+const express = require('express')
+const fs = require('fs')
+const path = require('path')
+const app = express()
+
+// 配置解析表单数据的中间件
+app.use(express.urlencoded({ extended: false }))
+
+// 一定要在路由之前,配置 cors 这个中间件,从而解决接口跨域的问题
+const cors = require('cors')
+app.use(cors())
+
+// 必须在配置 cors 中间件之前,配置 JSONP 的接口
+app.post('/save', (req, res) => {
+    const data = JSON.parse(req.body.q)
+    const locale = req.body.locale
+    fs.writeFile(path.join(__dirname, '..', 'locales', locale + '.json'), JSON.stringify(data, null, 4), err => {
+        if (err) {
+            return
+        }
+        fs.writeFile(path.join(__dirname, '..', '..', '..', 'src', 'locales', locale + '.json'), JSON.stringify(data, null, 4), () => {
+            res.send({ success: true, code: 1, data }).end()
+        })
+    })
+})
+// 导入路由模块
+// const router = require('./16.apiRouter')
+// // 把路由模块,注册到 app 上
+// app.use('/api', router)
+
+// 启动服务器
+app.listen(9091, () => {
+    console.log('express server running at http://127.0.0.1')
+})

+ 4 - 0
public/smart-kankan.html

@@ -27,5 +27,9 @@
         <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="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_nvvps2z7q3.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>

File diff suppressed because it is too large
+ 11 - 0
public/static/ext/animate/animate.min.css


File diff suppressed because it is too large
+ 1 - 0
public/static/ext/base64.min.js


+ 539 - 0
public/static/ext/iconfont/demo.css

@@ -0,0 +1,539 @@
+/* Logo 字体 */
+@font-face {
+  font-family: "iconfont logo";
+  src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834');
+  src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix') format('embedded-opentype'),
+    url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'),
+    url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'),
+    url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg');
+}
+
+.logo {
+  font-family: "iconfont logo";
+  font-size: 160px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+/* tabs */
+.nav-tabs {
+  position: relative;
+}
+
+.nav-tabs .nav-more {
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  height: 42px;
+  line-height: 42px;
+  color: #666;
+}
+
+#tabs {
+  border-bottom: 1px solid #eee;
+}
+
+#tabs li {
+  cursor: pointer;
+  width: 100px;
+  height: 40px;
+  line-height: 40px;
+  text-align: center;
+  font-size: 16px;
+  border-bottom: 2px solid transparent;
+  position: relative;
+  z-index: 1;
+  margin-bottom: -1px;
+  color: #666;
+}
+
+
+#tabs .active {
+  border-bottom-color: #f00;
+  color: #222;
+}
+
+.tab-container .content {
+  display: none;
+}
+
+/* 页面布局 */
+.main {
+  padding: 30px 100px;
+  width: 960px;
+  margin: 0 auto;
+}
+
+.main .logo {
+  color: #333;
+  text-align: left;
+  margin-bottom: 30px;
+  line-height: 1;
+  height: 110px;
+  margin-top: -50px;
+  overflow: hidden;
+  *zoom: 1;
+}
+
+.main .logo a {
+  font-size: 160px;
+  color: #333;
+}
+
+.helps {
+  margin-top: 40px;
+}
+
+.helps pre {
+  padding: 20px;
+  margin: 10px 0;
+  border: solid 1px #e7e1cd;
+  background-color: #fffdef;
+  overflow: auto;
+}
+
+.icon_lists {
+  width: 100% !important;
+  overflow: hidden;
+  *zoom: 1;
+}
+
+.icon_lists li {
+  width: 100px;
+  margin-bottom: 10px;
+  margin-right: 20px;
+  text-align: center;
+  list-style: none !important;
+  cursor: default;
+}
+
+.icon_lists li .code-name {
+  line-height: 1.2;
+}
+
+.icon_lists .icon {
+  display: block;
+  height: 100px;
+  line-height: 100px;
+  font-size: 42px;
+  margin: 10px auto;
+  color: #333;
+  -webkit-transition: font-size 0.25s linear, width 0.25s linear;
+  -moz-transition: font-size 0.25s linear, width 0.25s linear;
+  transition: font-size 0.25s linear, width 0.25s linear;
+}
+
+.icon_lists .icon:hover {
+  font-size: 100px;
+}
+
+.icon_lists .svg-icon {
+  /* 通过设置 font-size 来改变图标大小 */
+  width: 1em;
+  /* 图标和文字相邻时,垂直对齐 */
+  vertical-align: -0.15em;
+  /* 通过设置 color 来改变 SVG 的颜色/fill */
+  fill: currentColor;
+  /* path 和 stroke 溢出 viewBox 部分在 IE 下会显示
+      normalize.css 中也包含这行 */
+  overflow: hidden;
+}
+
+.icon_lists li .name,
+.icon_lists li .code-name {
+  color: #666;
+}
+
+/* markdown 样式 */
+.markdown {
+  color: #666;
+  font-size: 14px;
+  line-height: 1.8;
+}
+
+.highlight {
+  line-height: 1.5;
+}
+
+.markdown img {
+  vertical-align: middle;
+  max-width: 100%;
+}
+
+.markdown h1 {
+  color: #404040;
+  font-weight: 500;
+  line-height: 40px;
+  margin-bottom: 24px;
+}
+
+.markdown h2,
+.markdown h3,
+.markdown h4,
+.markdown h5,
+.markdown h6 {
+  color: #404040;
+  margin: 1.6em 0 0.6em 0;
+  font-weight: 500;
+  clear: both;
+}
+
+.markdown h1 {
+  font-size: 28px;
+}
+
+.markdown h2 {
+  font-size: 22px;
+}
+
+.markdown h3 {
+  font-size: 16px;
+}
+
+.markdown h4 {
+  font-size: 14px;
+}
+
+.markdown h5 {
+  font-size: 12px;
+}
+
+.markdown h6 {
+  font-size: 12px;
+}
+
+.markdown hr {
+  height: 1px;
+  border: 0;
+  background: #e9e9e9;
+  margin: 16px 0;
+  clear: both;
+}
+
+.markdown p {
+  margin: 1em 0;
+}
+
+.markdown>p,
+.markdown>blockquote,
+.markdown>.highlight,
+.markdown>ol,
+.markdown>ul {
+  width: 80%;
+}
+
+.markdown ul>li {
+  list-style: circle;
+}
+
+.markdown>ul li,
+.markdown blockquote ul>li {
+  margin-left: 20px;
+  padding-left: 4px;
+}
+
+.markdown>ul li p,
+.markdown>ol li p {
+  margin: 0.6em 0;
+}
+
+.markdown ol>li {
+  list-style: decimal;
+}
+
+.markdown>ol li,
+.markdown blockquote ol>li {
+  margin-left: 20px;
+  padding-left: 4px;
+}
+
+.markdown code {
+  margin: 0 3px;
+  padding: 0 5px;
+  background: #eee;
+  border-radius: 3px;
+}
+
+.markdown strong,
+.markdown b {
+  font-weight: 600;
+}
+
+.markdown>table {
+  border-collapse: collapse;
+  border-spacing: 0px;
+  empty-cells: show;
+  border: 1px solid #e9e9e9;
+  width: 95%;
+  margin-bottom: 24px;
+}
+
+.markdown>table th {
+  white-space: nowrap;
+  color: #333;
+  font-weight: 600;
+}
+
+.markdown>table th,
+.markdown>table td {
+  border: 1px solid #e9e9e9;
+  padding: 8px 16px;
+  text-align: left;
+}
+
+.markdown>table th {
+  background: #F7F7F7;
+}
+
+.markdown blockquote {
+  font-size: 90%;
+  color: #999;
+  border-left: 4px solid #e9e9e9;
+  padding-left: 0.8em;
+  margin: 1em 0;
+}
+
+.markdown blockquote p {
+  margin: 0;
+}
+
+.markdown .anchor {
+  opacity: 0;
+  transition: opacity 0.3s ease;
+  margin-left: 8px;
+}
+
+.markdown .waiting {
+  color: #ccc;
+}
+
+.markdown h1:hover .anchor,
+.markdown h2:hover .anchor,
+.markdown h3:hover .anchor,
+.markdown h4:hover .anchor,
+.markdown h5:hover .anchor,
+.markdown h6:hover .anchor {
+  opacity: 1;
+  display: inline-block;
+}
+
+.markdown>br,
+.markdown>p>br {
+  clear: both;
+}
+
+
+.hljs {
+  display: block;
+  background: white;
+  padding: 0.5em;
+  color: #333333;
+  overflow-x: auto;
+}
+
+.hljs-comment,
+.hljs-meta {
+  color: #969896;
+}
+
+.hljs-string,
+.hljs-variable,
+.hljs-template-variable,
+.hljs-strong,
+.hljs-emphasis,
+.hljs-quote {
+  color: #df5000;
+}
+
+.hljs-keyword,
+.hljs-selector-tag,
+.hljs-type {
+  color: #a71d5d;
+}
+
+.hljs-literal,
+.hljs-symbol,
+.hljs-bullet,
+.hljs-attribute {
+  color: #0086b3;
+}
+
+.hljs-section,
+.hljs-name {
+  color: #63a35c;
+}
+
+.hljs-tag {
+  color: #333333;
+}
+
+.hljs-title,
+.hljs-attr,
+.hljs-selector-id,
+.hljs-selector-class,
+.hljs-selector-attr,
+.hljs-selector-pseudo {
+  color: #795da3;
+}
+
+.hljs-addition {
+  color: #55a532;
+  background-color: #eaffea;
+}
+
+.hljs-deletion {
+  color: #bd2c00;
+  background-color: #ffecec;
+}
+
+.hljs-link {
+  text-decoration: underline;
+}
+
+/* 代码高亮 */
+/* PrismJS 1.15.0
+https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */
+/**
+ * prism.js default theme for JavaScript, CSS and HTML
+ * Based on dabblet (http://dabblet.com)
+ * @author Lea Verou
+ */
+code[class*="language-"],
+pre[class*="language-"] {
+  color: black;
+  background: none;
+  text-shadow: 0 1px white;
+  font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
+  text-align: left;
+  white-space: pre;
+  word-spacing: normal;
+  word-break: normal;
+  word-wrap: normal;
+  line-height: 1.5;
+
+  -moz-tab-size: 4;
+  -o-tab-size: 4;
+  tab-size: 4;
+
+  -webkit-hyphens: none;
+  -moz-hyphens: none;
+  -ms-hyphens: none;
+  hyphens: none;
+}
+
+pre[class*="language-"]::-moz-selection,
+pre[class*="language-"] ::-moz-selection,
+code[class*="language-"]::-moz-selection,
+code[class*="language-"] ::-moz-selection {
+  text-shadow: none;
+  background: #b3d4fc;
+}
+
+pre[class*="language-"]::selection,
+pre[class*="language-"] ::selection,
+code[class*="language-"]::selection,
+code[class*="language-"] ::selection {
+  text-shadow: none;
+  background: #b3d4fc;
+}
+
+@media print {
+
+  code[class*="language-"],
+  pre[class*="language-"] {
+    text-shadow: none;
+  }
+}
+
+/* Code blocks */
+pre[class*="language-"] {
+  padding: 1em;
+  margin: .5em 0;
+  overflow: auto;
+}
+
+:not(pre)>code[class*="language-"],
+pre[class*="language-"] {
+  background: #f5f2f0;
+}
+
+/* Inline code */
+:not(pre)>code[class*="language-"] {
+  padding: .1em;
+  border-radius: .3em;
+  white-space: normal;
+}
+
+.token.comment,
+.token.prolog,
+.token.doctype,
+.token.cdata {
+  color: slategray;
+}
+
+.token.punctuation {
+  color: #999;
+}
+
+.namespace {
+  opacity: .7;
+}
+
+.token.property,
+.token.tag,
+.token.boolean,
+.token.number,
+.token.constant,
+.token.symbol,
+.token.deleted {
+  color: #905;
+}
+
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted {
+  color: #690;
+}
+
+.token.operator,
+.token.entity,
+.token.url,
+.language-css .token.string,
+.style .token.string {
+  color: #9a6e3a;
+  background: hsla(0, 0%, 100%, .5);
+}
+
+.token.atrule,
+.token.attr-value,
+.token.keyword {
+  color: #07a;
+}
+
+.token.function,
+.token.class-name {
+  color: #DD4A68;
+}
+
+.token.regex,
+.token.important,
+.token.variable {
+  color: #e90;
+}
+
+.token.important,
+.token.bold {
+  font-weight: bold;
+}
+
+.token.italic {
+  font-style: italic;
+}
+
+.token.entity {
+  cursor: help;
+}

File diff suppressed because it is too large
+ 3385 - 0
public/static/ext/iconfont/demo_index.html


+ 571 - 0
public/static/ext/iconfont/iconfont.css

@@ -0,0 +1,571 @@
+@font-face {
+  font-family: "iconfont"; /* Project id 2596172 */
+  src: url('iconfont.woff2?t=1659061669448') format('woff2'),
+       url('iconfont.woff?t=1659061669448') format('woff'),
+       url('iconfont.ttf?t=1659061669448') format('truetype');
+}
+
+.iconfont {
+  font-family: "iconfont" !important;
+  font-size: 16px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-show_share:before {
+  content: "\e690";
+}
+
+.icon-show_more_ruler:before {
+  content: "\e67b";
+}
+
+.icon-transparency:before {
+  content: "\e6d7";
+}
+
+.icon-mosaic_e:before {
+  content: "\e6d2";
+}
+
+.icon-eraser:before {
+  content: "\e6d3";
+}
+
+.icon-video2:before {
+  content: "\e6d4";
+}
+
+.icon-down:before {
+  content: "\e6d5";
+}
+
+.icon-qingkong-copy-copy:before {
+  content: "\e68f";
+}
+
+.icon-_qq:before {
+  content: "\e64b";
+}
+
+.icon-_friend:before {
+  content: "\e64c";
+}
+
+.icon-link1:before {
+  content: "\e6ff";
+}
+
+.icon-icon_share_facebook:before {
+  content: "\e745";
+}
+
+.icon-icon_share_whatsapp:before {
+  content: "\e746";
+}
+
+.icon-_wechat:before {
+  content: "\e64a";
+}
+
+.icon-watermark:before {
+  content: "\e6cb";
+}
+
+.icon-camera_h:before {
+  content: "\e6c7";
+}
+
+.icon-a-1V1:before {
+  content: "\e6c8";
+}
+
+.icon-hengbiaoEN:before {
+  content: "\e6b9";
+}
+
+.icon-hot_spot:before {
+  content: "\e6b8";
+}
+
+.icon-foot:before {
+  content: "\e6ba";
+}
+
+.icon-music-t:before {
+  content: "\e6b7";
+}
+
+.icon-yes:before {
+  content: "\e6b5";
+}
+
+.icon-no:before {
+  content: "\e6b6";
+}
+
+.icon-floor_rename:before {
+  content: "\e75b";
+}
+
+.icon-play_stop:before {
+  content: "\e6b4";
+}
+
+.icon-scene_auto:before {
+  content: "\e721";
+}
+
+.icon-compass:before {
+  content: "\e6b3";
+}
+
+.icon-h-d:before {
+  content: "\e6b2";
+}
+
+.icon-scene_screen:before {
+  content: "\e717";
+}
+
+.icon-scene_full:before {
+  content: "\e712";
+}
+
+.icon-scene_window:before {
+  content: "\e713";
+}
+
+.icon-_back:before {
+  content: "\e609";
+}
+
+.icon-show_back:before {
+  content: "\e678";
+}
+
+.icon-show_more_share:before {
+  content: "\e680";
+}
+
+.icon-show_more_finish:before {
+  content: "\e67a";
+}
+
+.icon-show_more_music:before {
+  content: "\e67c";
+}
+
+.icon-show_map_collect:before {
+  content: "\e679";
+}
+
+.icon-data-j:before {
+  content: "\e6b1";
+}
+
+.icon-cancel:before {
+  content: "\e688";
+}
+
+.icon-affirm:before {
+  content: "\e689";
+}
+
+.icon-checkbox_p:before {
+  content: "\e6b0";
+}
+
+.icon-left:before {
+  content: "\e6ae";
+}
+
+.icon-right:before {
+  content: "\e6af";
+}
+
+.icon-_loading_:before {
+  content: "\e627";
+}
+
+.icon-show_function_collect:before {
+  content: "\e687";
+}
+
+.icon-show_more:before {
+  content: "\e67e";
+}
+
+.icon-d-r:before {
+  content: "\e68d";
+}
+
+.icon-up-a:before {
+  content: "\e68e";
+}
+
+.icon-fanzhuan:before {
+  content: "\e6fa";
+}
+
+.icon-cad-neiqiang:before {
+  content: "\e698";
+}
+
+.icon-cad-waiqiang:before {
+  content: "\e69a";
+}
+
+.icon-cad-shuangkaimen:before {
+  content: "\e69b";
+}
+
+.icon-cad-zimumen:before {
+  content: "\e69c";
+}
+
+.icon-cad-men:before {
+  content: "\e69d";
+}
+
+.icon-cad-yimen:before {
+  content: "\e69e";
+}
+
+.icon-cad-zhediemen:before {
+  content: "\e69f";
+}
+
+.icon-cad-luodichuang:before {
+  content: "\e6a0";
+}
+
+.icon-cad-chuang:before {
+  content: "\e6a1";
+}
+
+.icon-cad-piaochuang:before {
+  content: "\e6a2";
+}
+
+.icon-cad-uxingchuang:before {
+  content: "\e6a4";
+}
+
+.icon-cad-lxingchuang:before {
+  content: "\e6a5";
+}
+
+.icon-cad-lpiaochuang:before {
+  content: "\e6a6";
+}
+
+.icon-cad-upiaochuang:before {
+  content: "\e6a7";
+}
+
+.icon-cad-liang:before {
+  content: "\e6a8";
+}
+
+.icon-cad-yandao:before {
+  content: "\e6a9";
+}
+
+.icon-cad-zhuzi:before {
+  content: "\e6aa";
+}
+
+.icon-cad-dianti:before {
+  content: "\e6ab";
+}
+
+.icon-cad-loudao:before {
+  content: "\e6ac";
+}
+
+.icon-cad-dange:before {
+  content: "\e6ad";
+}
+
+.icon-cad-yakou:before {
+  content: "\e699";
+}
+
+.icon-more:before {
+  content: "\e600";
+}
+
+.icon-mosaic1:before {
+  content: "\e697";
+}
+
+.icon-hengbiaoCN:before {
+  content: "\e6a3";
+}
+
+.icon-nor:before {
+  content: "\e696";
+}
+
+.icon-checkbox1:before {
+  content: "\e65d";
+}
+
+.icon-rotate:before {
+  content: "\e695";
+}
+
+.icon-adapt:before {
+  content: "\e692";
+}
+
+.icon-recover:before {
+  content: "\e693";
+}
+
+.icon-repeal:before {
+  content: "\e694";
+}
+
+.icon-reset:before {
+  content: "\e65a";
+}
+
+.icon-course1:before {
+  content: "\e68c";
+}
+
+.icon-publish:before {
+  content: "\e68a";
+}
+
+.icon-save:before {
+  content: "\e68b";
+}
+
+.icon-checkbox:before {
+  content: "\e649";
+}
+
+.icon-mosaic:before {
+  content: "\e646";
+}
+
+.icon-media:before {
+  content: "\e647";
+}
+
+.icon-pic:before {
+  content: "\e648";
+}
+
+.icon-eye_f:before {
+  content: "\e644";
+}
+
+.icon-eye_c:before {
+  content: "\e645";
+}
+
+.icon-size-o:before {
+  content: "\e640";
+}
+
+.icon-size-f:before {
+  content: "\e641";
+}
+
+.icon-magnify:before {
+  content: "\e642";
+}
+
+.icon-reduce:before {
+  content: "\e643";
+}
+
+.icon-video1:before {
+  content: "\e63b";
+}
+
+.icon-uploading_s:before {
+  content: "\e63c";
+}
+
+.icon-path:before {
+  content: "\e63d";
+}
+
+.icon-record:before {
+  content: "\e63e";
+}
+
+.icon-clear:before {
+  content: "\e63f";
+}
+
+.icon-pause:before {
+  content: "\e636";
+}
+
+.icon-preview:before {
+  content: "\e63a";
+}
+
+.icon-full:before {
+  content: "\e638";
+}
+
+.icon-add:before {
+  content: "\e631";
+}
+
+.icon-del:before {
+  content: "\e632";
+}
+
+.icon-close:before {
+  content: "\e633";
+}
+
+.icon-web:before {
+  content: "\e635";
+}
+
+.icon-music:before {
+  content: "\e637";
+}
+
+.icon-uploading1:before {
+  content: "\e639";
+}
+
+.icon-state_e:before {
+  content: "\e624";
+}
+
+.icon-state_f:before {
+  content: "\e625";
+}
+
+.icon-state_s:before {
+  content: "\e626";
+}
+
+.icon-vip_uploading:before {
+  content: "\e623";
+}
+
+.icon-eye-n:before {
+  content: "\e621";
+}
+
+.icon-eye-s:before {
+  content: "\e622";
+}
+
+.icon-course:before {
+  content: "\e620";
+}
+
+.icon-edit:before {
+  content: "\e61f";
+}
+
+.icon-help:before {
+  content: "\e615";
+}
+
+.icon-rule:before {
+  content: "\e617";
+}
+
+.icon-link:before {
+  content: "\e618";
+}
+
+.icon-uploading:before {
+  content: "\e619";
+}
+
+.icon-vr:before {
+  content: "\e61a";
+}
+
+.icon-complete:before {
+  content: "\e61b";
+}
+
+.icon-download:before {
+  content: "\e61c";
+}
+
+.icon-pull-down:before {
+  content: "\e61d";
+}
+
+.icon-pull-up:before {
+  content: "\e61e";
+}
+
+.icon-updown:before {
+  content: "\e613";
+}
+
+.icon-scene:before {
+  content: "\e614";
+}
+
+.icon-message:before {
+  content: "\e60c";
+}
+
+.icon-basic:before {
+  content: "\e60d";
+}
+
+.icon-hotspot:before {
+  content: "\e60e";
+}
+
+.icon-guide:before {
+  content: "\e60f";
+}
+
+.icon-wander:before {
+  content: "\e610";
+}
+
+.icon-house:before {
+  content: "\e611";
+}
+
+.icon-video:before {
+  content: "\e612";
+}
+
+.icon-show_3d_normal:before {
+  content: "\e677";
+}
+
+.icon-show_plane_normal:before {
+  content: "\e67d";
+}
+
+.icon-show_roaming_selected:before {
+  content: "\e682";
+}
+
+.icon-show_plane_selected:before {
+  content: "\e684";
+}
+
+.icon-show_3d_selected:before {
+  content: "\e685";
+}
+
+.icon-show_roaming_normal:before {
+  content: "\e686";
+}
+

File diff suppressed because it is too large
+ 1 - 0
public/static/ext/iconfont/iconfont.js


+ 982 - 0
public/static/ext/iconfont/iconfont.json

@@ -0,0 +1,982 @@
+{
+  "id": "2596172",
+  "name": "四维看看编辑器V4",
+  "font_family": "iconfont",
+  "css_prefix_text": "icon-",
+  "description": "四维看看编辑器设计优化",
+  "glyphs": [
+    {
+      "icon_id": "20600368",
+      "name": "show_share",
+      "font_class": "show_share",
+      "unicode": "e690",
+      "unicode_decimal": 59024
+    },
+    {
+      "icon_id": "19543928",
+      "name": "show_more_ruler",
+      "font_class": "show_more_ruler",
+      "unicode": "e67b",
+      "unicode_decimal": 59003
+    },
+    {
+      "icon_id": "30499411",
+      "name": "transparency",
+      "font_class": "transparency",
+      "unicode": "e6d7",
+      "unicode_decimal": 59095
+    },
+    {
+      "icon_id": "30490892",
+      "name": "mosaic_e",
+      "font_class": "mosaic_e",
+      "unicode": "e6d2",
+      "unicode_decimal": 59090
+    },
+    {
+      "icon_id": "30490893",
+      "name": "eraser",
+      "font_class": "eraser",
+      "unicode": "e6d3",
+      "unicode_decimal": 59091
+    },
+    {
+      "icon_id": "30490894",
+      "name": "video",
+      "font_class": "video2",
+      "unicode": "e6d4",
+      "unicode_decimal": 59092
+    },
+    {
+      "icon_id": "30490946",
+      "name": "down",
+      "font_class": "down",
+      "unicode": "e6d5",
+      "unicode_decimal": 59093
+    },
+    {
+      "icon_id": "3141534",
+      "name": "清空",
+      "font_class": "qingkong-copy-copy",
+      "unicode": "e68f",
+      "unicode_decimal": 59023
+    },
+    {
+      "icon_id": "7878582",
+      "name": "QQ",
+      "font_class": "_qq",
+      "unicode": "e64b",
+      "unicode_decimal": 58955
+    },
+    {
+      "icon_id": "7878584",
+      "name": "朋友",
+      "font_class": "_friend",
+      "unicode": "e64c",
+      "unicode_decimal": 58956
+    },
+    {
+      "icon_id": "14887802",
+      "name": "link",
+      "font_class": "link1",
+      "unicode": "e6ff",
+      "unicode_decimal": 59135
+    },
+    {
+      "icon_id": "26296900",
+      "name": "icon_share_facebook",
+      "font_class": "icon_share_facebook",
+      "unicode": "e745",
+      "unicode_decimal": 59205
+    },
+    {
+      "icon_id": "26296901",
+      "name": "icon_share_whatsapp",
+      "font_class": "icon_share_whatsapp",
+      "unicode": "e746",
+      "unicode_decimal": 59206
+    },
+    {
+      "icon_id": "7878583",
+      "name": "微信",
+      "font_class": "_wechat",
+      "unicode": "e64a",
+      "unicode_decimal": 58954
+    },
+    {
+      "icon_id": "30230568",
+      "name": "watermark",
+      "font_class": "watermark",
+      "unicode": "e6cb",
+      "unicode_decimal": 59083
+    },
+    {
+      "icon_id": "30230353",
+      "name": "camera_h",
+      "font_class": "camera_h",
+      "unicode": "e6c7",
+      "unicode_decimal": 59079
+    },
+    {
+      "icon_id": "30230374",
+      "name": "1V1",
+      "font_class": "a-1V1",
+      "unicode": "e6c8",
+      "unicode_decimal": 59080
+    },
+    {
+      "icon_id": "12324810",
+      "name": "横标EN",
+      "font_class": "hengbiaoEN",
+      "unicode": "e6b9",
+      "unicode_decimal": 59065
+    },
+    {
+      "icon_id": "22788718",
+      "name": "hot_spot",
+      "font_class": "hot_spot",
+      "unicode": "e6b8",
+      "unicode_decimal": 59064
+    },
+    {
+      "icon_id": "29982018",
+      "name": "foot",
+      "font_class": "foot",
+      "unicode": "e6ba",
+      "unicode_decimal": 59066
+    },
+    {
+      "icon_id": "29365471",
+      "name": "music-t",
+      "font_class": "music-t",
+      "unicode": "e6b7",
+      "unicode_decimal": 59063
+    },
+    {
+      "icon_id": "29346730",
+      "name": "yes",
+      "font_class": "yes",
+      "unicode": "e6b5",
+      "unicode_decimal": 59061
+    },
+    {
+      "icon_id": "29346731",
+      "name": "no",
+      "font_class": "no",
+      "unicode": "e6b6",
+      "unicode_decimal": 59062
+    },
+    {
+      "icon_id": "20764383",
+      "name": "floor_rename",
+      "font_class": "floor_rename",
+      "unicode": "e75b",
+      "unicode_decimal": 59227
+    },
+    {
+      "icon_id": "29255507",
+      "name": "play_stop",
+      "font_class": "play_stop",
+      "unicode": "e6b4",
+      "unicode_decimal": 59060
+    },
+    {
+      "icon_id": "16303117",
+      "name": "scene_auto",
+      "font_class": "scene_auto",
+      "unicode": "e721",
+      "unicode_decimal": 59169
+    },
+    {
+      "icon_id": "28895680",
+      "name": "compass",
+      "font_class": "compass",
+      "unicode": "e6b3",
+      "unicode_decimal": 59059
+    },
+    {
+      "icon_id": "28351771",
+      "name": "h-d",
+      "font_class": "h-d",
+      "unicode": "e6b2",
+      "unicode_decimal": 59058
+    },
+    {
+      "icon_id": "16270567",
+      "name": "scene_screen",
+      "font_class": "scene_screen",
+      "unicode": "e717",
+      "unicode_decimal": 59159
+    },
+    {
+      "icon_id": "15936377",
+      "name": "scene_full",
+      "font_class": "scene_full",
+      "unicode": "e712",
+      "unicode_decimal": 59154
+    },
+    {
+      "icon_id": "15936378",
+      "name": "scene_window",
+      "font_class": "scene_window",
+      "unicode": "e713",
+      "unicode_decimal": 59155
+    },
+    {
+      "icon_id": "7857935",
+      "name": "返回",
+      "font_class": "_back",
+      "unicode": "e609",
+      "unicode_decimal": 58889
+    },
+    {
+      "icon_id": "20066553",
+      "name": "show_back",
+      "font_class": "show_back",
+      "unicode": "e678",
+      "unicode_decimal": 59000
+    },
+    {
+      "icon_id": "20066555",
+      "name": "show_more_share",
+      "font_class": "show_more_share",
+      "unicode": "e680",
+      "unicode_decimal": 59008
+    },
+    {
+      "icon_id": "19543927",
+      "name": "show_more_finish",
+      "font_class": "show_more_finish",
+      "unicode": "e67a",
+      "unicode_decimal": 59002
+    },
+    {
+      "icon_id": "19543929",
+      "name": "show_more_music",
+      "font_class": "show_more_music",
+      "unicode": "e67c",
+      "unicode_decimal": 59004
+    },
+    {
+      "icon_id": "19543926",
+      "name": "show_map_collect",
+      "font_class": "show_map_collect",
+      "unicode": "e679",
+      "unicode_decimal": 59001
+    },
+    {
+      "icon_id": "27985200",
+      "name": "data-j",
+      "font_class": "data-j",
+      "unicode": "e6b1",
+      "unicode_decimal": 59057
+    },
+    {
+      "icon_id": "26690641",
+      "name": "cancel",
+      "font_class": "cancel",
+      "unicode": "e688",
+      "unicode_decimal": 59016
+    },
+    {
+      "icon_id": "26690677",
+      "name": "affirm",
+      "font_class": "affirm",
+      "unicode": "e689",
+      "unicode_decimal": 59017
+    },
+    {
+      "icon_id": "27896904",
+      "name": "checkbox_p",
+      "font_class": "checkbox_p",
+      "unicode": "e6b0",
+      "unicode_decimal": 59056
+    },
+    {
+      "icon_id": "27765016",
+      "name": "left",
+      "font_class": "left",
+      "unicode": "e6ae",
+      "unicode_decimal": 59054
+    },
+    {
+      "icon_id": "27765017",
+      "name": "right",
+      "font_class": "right",
+      "unicode": "e6af",
+      "unicode_decimal": 59055
+    },
+    {
+      "icon_id": "7858065",
+      "name": "loading",
+      "font_class": "_loading_",
+      "unicode": "e627",
+      "unicode_decimal": 58919
+    },
+    {
+      "icon_id": "20066572",
+      "name": "show_function_collect",
+      "font_class": "show_function_collect",
+      "unicode": "e687",
+      "unicode_decimal": 59015
+    },
+    {
+      "icon_id": "19543931",
+      "name": "show_more",
+      "font_class": "show_more",
+      "unicode": "e67e",
+      "unicode_decimal": 59006
+    },
+    {
+      "icon_id": "26914809",
+      "name": "d-r",
+      "font_class": "d-r",
+      "unicode": "e68d",
+      "unicode_decimal": 59021
+    },
+    {
+      "icon_id": "26914810",
+      "name": "up-a",
+      "font_class": "up-a",
+      "unicode": "e68e",
+      "unicode_decimal": 59022
+    },
+    {
+      "icon_id": "14510564",
+      "name": "edit_mirror",
+      "font_class": "fanzhuan",
+      "unicode": "e6fa",
+      "unicode_decimal": 59130
+    },
+    {
+      "icon_id": "27294582",
+      "name": "neiqiang",
+      "font_class": "cad-neiqiang",
+      "unicode": "e698",
+      "unicode_decimal": 59032
+    },
+    {
+      "icon_id": "27294583",
+      "name": "waiqiang",
+      "font_class": "cad-waiqiang",
+      "unicode": "e69a",
+      "unicode_decimal": 59034
+    },
+    {
+      "icon_id": "27294616",
+      "name": "shuangkaimen",
+      "font_class": "cad-shuangkaimen",
+      "unicode": "e69b",
+      "unicode_decimal": 59035
+    },
+    {
+      "icon_id": "27294617",
+      "name": "zimumen",
+      "font_class": "cad-zimumen",
+      "unicode": "e69c",
+      "unicode_decimal": 59036
+    },
+    {
+      "icon_id": "27294618",
+      "name": "men",
+      "font_class": "cad-men",
+      "unicode": "e69d",
+      "unicode_decimal": 59037
+    },
+    {
+      "icon_id": "27294620",
+      "name": "yimen",
+      "font_class": "cad-yimen",
+      "unicode": "e69e",
+      "unicode_decimal": 59038
+    },
+    {
+      "icon_id": "27294621",
+      "name": "zhediemen",
+      "font_class": "cad-zhediemen",
+      "unicode": "e69f",
+      "unicode_decimal": 59039
+    },
+    {
+      "icon_id": "27294699",
+      "name": "luodichuang",
+      "font_class": "cad-luodichuang",
+      "unicode": "e6a0",
+      "unicode_decimal": 59040
+    },
+    {
+      "icon_id": "27294700",
+      "name": "chuang",
+      "font_class": "cad-chuang",
+      "unicode": "e6a1",
+      "unicode_decimal": 59041
+    },
+    {
+      "icon_id": "27294701",
+      "name": "piaochuang",
+      "font_class": "cad-piaochuang",
+      "unicode": "e6a2",
+      "unicode_decimal": 59042
+    },
+    {
+      "icon_id": "27294702",
+      "name": "uxingchuang",
+      "font_class": "cad-uxingchuang",
+      "unicode": "e6a4",
+      "unicode_decimal": 59044
+    },
+    {
+      "icon_id": "27294703",
+      "name": "lxingchuang",
+      "font_class": "cad-lxingchuang",
+      "unicode": "e6a5",
+      "unicode_decimal": 59045
+    },
+    {
+      "icon_id": "27294704",
+      "name": "lpiaochuang",
+      "font_class": "cad-lpiaochuang",
+      "unicode": "e6a6",
+      "unicode_decimal": 59046
+    },
+    {
+      "icon_id": "27294705",
+      "name": "upiaochuang",
+      "font_class": "cad-upiaochuang",
+      "unicode": "e6a7",
+      "unicode_decimal": 59047
+    },
+    {
+      "icon_id": "27294743",
+      "name": "liang",
+      "font_class": "cad-liang",
+      "unicode": "e6a8",
+      "unicode_decimal": 59048
+    },
+    {
+      "icon_id": "27294744",
+      "name": "yandao",
+      "font_class": "cad-yandao",
+      "unicode": "e6a9",
+      "unicode_decimal": 59049
+    },
+    {
+      "icon_id": "27294745",
+      "name": "zhuzi",
+      "font_class": "cad-zhuzi",
+      "unicode": "e6aa",
+      "unicode_decimal": 59050
+    },
+    {
+      "icon_id": "27294746",
+      "name": "dianti",
+      "font_class": "cad-dianti",
+      "unicode": "e6ab",
+      "unicode_decimal": 59051
+    },
+    {
+      "icon_id": "27294747",
+      "name": "loudao",
+      "font_class": "cad-loudao",
+      "unicode": "e6ac",
+      "unicode_decimal": 59052
+    },
+    {
+      "icon_id": "27297593",
+      "name": "dange",
+      "font_class": "cad-dange",
+      "unicode": "e6ad",
+      "unicode_decimal": 59053
+    },
+    {
+      "icon_id": "27294584",
+      "name": "yakou",
+      "font_class": "cad-yakou",
+      "unicode": "e699",
+      "unicode_decimal": 59033
+    },
+    {
+      "icon_id": "11304931",
+      "name": "more read",
+      "font_class": "more",
+      "unicode": "e600",
+      "unicode_decimal": 58880
+    },
+    {
+      "icon_id": "27264933",
+      "name": "mosaic",
+      "font_class": "mosaic1",
+      "unicode": "e697",
+      "unicode_decimal": 59031
+    },
+    {
+      "icon_id": "12324809",
+      "name": "横标CN",
+      "font_class": "hengbiaoCN",
+      "unicode": "e6a3",
+      "unicode_decimal": 59043
+    },
+    {
+      "icon_id": "27200779",
+      "name": "nor",
+      "font_class": "nor",
+      "unicode": "e696",
+      "unicode_decimal": 59030
+    },
+    {
+      "icon_id": "25671886",
+      "name": "checkbox",
+      "font_class": "checkbox1",
+      "unicode": "e65d",
+      "unicode_decimal": 58973
+    },
+    {
+      "icon_id": "27198807",
+      "name": "rotate",
+      "font_class": "rotate",
+      "unicode": "e695",
+      "unicode_decimal": 59029
+    },
+    {
+      "icon_id": "27198774",
+      "name": "adapt",
+      "font_class": "adapt",
+      "unicode": "e692",
+      "unicode_decimal": 59026
+    },
+    {
+      "icon_id": "27198776",
+      "name": "recover",
+      "font_class": "recover",
+      "unicode": "e693",
+      "unicode_decimal": 59027
+    },
+    {
+      "icon_id": "27198777",
+      "name": "repeal",
+      "font_class": "repeal",
+      "unicode": "e694",
+      "unicode_decimal": 59028
+    },
+    {
+      "icon_id": "25654903",
+      "name": "reset",
+      "font_class": "reset",
+      "unicode": "e65a",
+      "unicode_decimal": 58970
+    },
+    {
+      "icon_id": "26971297",
+      "name": "course",
+      "font_class": "course1",
+      "unicode": "e68c",
+      "unicode_decimal": 59020
+    },
+    {
+      "icon_id": "26950708",
+      "name": "publish",
+      "font_class": "publish",
+      "unicode": "e68a",
+      "unicode_decimal": 59018
+    },
+    {
+      "icon_id": "26950711",
+      "name": "save",
+      "font_class": "save",
+      "unicode": "e68b",
+      "unicode_decimal": 59019
+    },
+    {
+      "icon_id": "23842269",
+      "name": "sel",
+      "font_class": "checkbox",
+      "unicode": "e649",
+      "unicode_decimal": 58953
+    },
+    {
+      "icon_id": "23786361",
+      "name": "mosaic",
+      "font_class": "mosaic",
+      "unicode": "e646",
+      "unicode_decimal": 58950
+    },
+    {
+      "icon_id": "23786362",
+      "name": "media",
+      "font_class": "media",
+      "unicode": "e647",
+      "unicode_decimal": 58951
+    },
+    {
+      "icon_id": "23786363",
+      "name": "pic",
+      "font_class": "pic",
+      "unicode": "e648",
+      "unicode_decimal": 58952
+    },
+    {
+      "icon_id": "23783948",
+      "name": "eye_f",
+      "font_class": "eye_f",
+      "unicode": "e644",
+      "unicode_decimal": 58948
+    },
+    {
+      "icon_id": "23783949",
+      "name": "eye_c",
+      "font_class": "eye_c",
+      "unicode": "e645",
+      "unicode_decimal": 58949
+    },
+    {
+      "icon_id": "23783512",
+      "name": "size-o",
+      "font_class": "size-o",
+      "unicode": "e640",
+      "unicode_decimal": 58944
+    },
+    {
+      "icon_id": "23783513",
+      "name": "size-f",
+      "font_class": "size-f",
+      "unicode": "e641",
+      "unicode_decimal": 58945
+    },
+    {
+      "icon_id": "23783514",
+      "name": "magnify",
+      "font_class": "magnify",
+      "unicode": "e642",
+      "unicode_decimal": 58946
+    },
+    {
+      "icon_id": "23783515",
+      "name": "reduce",
+      "font_class": "reduce",
+      "unicode": "e643",
+      "unicode_decimal": 58947
+    },
+    {
+      "icon_id": "23781429",
+      "name": "video",
+      "font_class": "video1",
+      "unicode": "e63b",
+      "unicode_decimal": 58939
+    },
+    {
+      "icon_id": "23781430",
+      "name": "uploading_s",
+      "font_class": "uploading_s",
+      "unicode": "e63c",
+      "unicode_decimal": 58940
+    },
+    {
+      "icon_id": "23781431",
+      "name": "path",
+      "font_class": "path",
+      "unicode": "e63d",
+      "unicode_decimal": 58941
+    },
+    {
+      "icon_id": "23781432",
+      "name": "record",
+      "font_class": "record",
+      "unicode": "e63e",
+      "unicode_decimal": 58942
+    },
+    {
+      "icon_id": "23781433",
+      "name": "clear",
+      "font_class": "clear",
+      "unicode": "e63f",
+      "unicode_decimal": 58943
+    },
+    {
+      "icon_id": "23773343",
+      "name": "pause",
+      "font_class": "pause",
+      "unicode": "e636",
+      "unicode_decimal": 58934
+    },
+    {
+      "icon_id": "23773344",
+      "name": "preview",
+      "font_class": "preview",
+      "unicode": "e63a",
+      "unicode_decimal": 58938
+    },
+    {
+      "icon_id": "23773141",
+      "name": "full",
+      "font_class": "full",
+      "unicode": "e638",
+      "unicode_decimal": 58936
+    },
+    {
+      "icon_id": "23773068",
+      "name": "add",
+      "font_class": "add",
+      "unicode": "e631",
+      "unicode_decimal": 58929
+    },
+    {
+      "icon_id": "23773069",
+      "name": "del",
+      "font_class": "del",
+      "unicode": "e632",
+      "unicode_decimal": 58930
+    },
+    {
+      "icon_id": "23773070",
+      "name": "close",
+      "font_class": "close",
+      "unicode": "e633",
+      "unicode_decimal": 58931
+    },
+    {
+      "icon_id": "23773072",
+      "name": "web",
+      "font_class": "web",
+      "unicode": "e635",
+      "unicode_decimal": 58933
+    },
+    {
+      "icon_id": "23773074",
+      "name": "music",
+      "font_class": "music",
+      "unicode": "e637",
+      "unicode_decimal": 58935
+    },
+    {
+      "icon_id": "23773076",
+      "name": "uploading",
+      "font_class": "uploading1",
+      "unicode": "e639",
+      "unicode_decimal": 58937
+    },
+    {
+      "icon_id": "22132762",
+      "name": "state_e",
+      "font_class": "state_e",
+      "unicode": "e624",
+      "unicode_decimal": 58916
+    },
+    {
+      "icon_id": "22132763",
+      "name": "state_f",
+      "font_class": "state_f",
+      "unicode": "e625",
+      "unicode_decimal": 58917
+    },
+    {
+      "icon_id": "22132764",
+      "name": "state_s",
+      "font_class": "state_s",
+      "unicode": "e626",
+      "unicode_decimal": 58918
+    },
+    {
+      "icon_id": "22130256",
+      "name": "vip_uploading",
+      "font_class": "vip_uploading",
+      "unicode": "e623",
+      "unicode_decimal": 58915
+    },
+    {
+      "icon_id": "22099675",
+      "name": "eye-n",
+      "font_class": "eye-n",
+      "unicode": "e621",
+      "unicode_decimal": 58913
+    },
+    {
+      "icon_id": "22099676",
+      "name": "eye-s",
+      "font_class": "eye-s",
+      "unicode": "e622",
+      "unicode_decimal": 58914
+    },
+    {
+      "icon_id": "22099634",
+      "name": "course",
+      "font_class": "course",
+      "unicode": "e620",
+      "unicode_decimal": 58912
+    },
+    {
+      "icon_id": "22099525",
+      "name": "edit",
+      "font_class": "edit",
+      "unicode": "e61f",
+      "unicode_decimal": 58911
+    },
+    {
+      "icon_id": "22099475",
+      "name": "help",
+      "font_class": "help",
+      "unicode": "e615",
+      "unicode_decimal": 58901
+    },
+    {
+      "icon_id": "22099478",
+      "name": "rule",
+      "font_class": "rule",
+      "unicode": "e617",
+      "unicode_decimal": 58903
+    },
+    {
+      "icon_id": "22099479",
+      "name": "link",
+      "font_class": "link",
+      "unicode": "e618",
+      "unicode_decimal": 58904
+    },
+    {
+      "icon_id": "22099480",
+      "name": "uploading",
+      "font_class": "uploading",
+      "unicode": "e619",
+      "unicode_decimal": 58905
+    },
+    {
+      "icon_id": "22099481",
+      "name": "vr",
+      "font_class": "vr",
+      "unicode": "e61a",
+      "unicode_decimal": 58906
+    },
+    {
+      "icon_id": "22099484",
+      "name": "complete",
+      "font_class": "complete",
+      "unicode": "e61b",
+      "unicode_decimal": 58907
+    },
+    {
+      "icon_id": "22099499",
+      "name": "download",
+      "font_class": "download",
+      "unicode": "e61c",
+      "unicode_decimal": 58908
+    },
+    {
+      "icon_id": "22099518",
+      "name": "pull-down",
+      "font_class": "pull-down",
+      "unicode": "e61d",
+      "unicode_decimal": 58909
+    },
+    {
+      "icon_id": "22099519",
+      "name": "pull-up",
+      "font_class": "pull-up",
+      "unicode": "e61e",
+      "unicode_decimal": 58910
+    },
+    {
+      "icon_id": "22099070",
+      "name": "updown",
+      "font_class": "updown",
+      "unicode": "e613",
+      "unicode_decimal": 58899
+    },
+    {
+      "icon_id": "22099071",
+      "name": "scene",
+      "font_class": "scene",
+      "unicode": "e614",
+      "unicode_decimal": 58900
+    },
+    {
+      "icon_id": "22099025",
+      "name": "message",
+      "font_class": "message",
+      "unicode": "e60c",
+      "unicode_decimal": 58892
+    },
+    {
+      "icon_id": "22099028",
+      "name": "basic",
+      "font_class": "basic",
+      "unicode": "e60d",
+      "unicode_decimal": 58893
+    },
+    {
+      "icon_id": "22099032",
+      "name": "hotspot",
+      "font_class": "hotspot",
+      "unicode": "e60e",
+      "unicode_decimal": 58894
+    },
+    {
+      "icon_id": "22099035",
+      "name": "guide",
+      "font_class": "guide",
+      "unicode": "e60f",
+      "unicode_decimal": 58895
+    },
+    {
+      "icon_id": "22099060",
+      "name": "wander",
+      "font_class": "wander",
+      "unicode": "e610",
+      "unicode_decimal": 58896
+    },
+    {
+      "icon_id": "22099068",
+      "name": "house",
+      "font_class": "house",
+      "unicode": "e611",
+      "unicode_decimal": 58897
+    },
+    {
+      "icon_id": "22099069",
+      "name": "video",
+      "font_class": "video",
+      "unicode": "e612",
+      "unicode_decimal": 58898
+    },
+    {
+      "icon_id": "20066551",
+      "name": "show_3d_normal",
+      "font_class": "show_3d_normal",
+      "unicode": "e677",
+      "unicode_decimal": 58999
+    },
+    {
+      "icon_id": "20066554",
+      "name": "show_plane_normal",
+      "font_class": "show_plane_normal",
+      "unicode": "e67d",
+      "unicode_decimal": 59005
+    },
+    {
+      "icon_id": "20066556",
+      "name": "show_roaming_selected",
+      "font_class": "show_roaming_selected",
+      "unicode": "e682",
+      "unicode_decimal": 59010
+    },
+    {
+      "icon_id": "20066557",
+      "name": "show_plane_selected",
+      "font_class": "show_plane_selected",
+      "unicode": "e684",
+      "unicode_decimal": 59012
+    },
+    {
+      "icon_id": "20066558",
+      "name": "show_3d_selected",
+      "font_class": "show_3d_selected",
+      "unicode": "e685",
+      "unicode_decimal": 59013
+    },
+    {
+      "icon_id": "20066559",
+      "name": "show_roaming_normal",
+      "font_class": "show_roaming_normal",
+      "unicode": "e686",
+      "unicode_decimal": 59014
+    }
+  ]
+}

BIN
public/static/ext/iconfont/iconfont.ttf


BIN
public/static/ext/iconfont/iconfont.woff


BIN
public/static/ext/iconfont/iconfont.woff2


+ 16 - 0
public/static/ext/mobile-detect.js

@@ -0,0 +1,16 @@
+(function(win) {
+    var orgLink = win.location.href
+    var newLink = ''
+    if (/iPhone|iPad|Android/i.test(win.navigator.userAgent)) {
+        if (orgLink.indexOf('smart-viewer.html') !== -1) {
+            newLink = orgLink.replace('smart-viewer.html', 'smart-sviewer.html')
+        }
+    } else {
+        if (orgLink.indexOf('smart-sviewer.html') !== -1) {
+            newLink = orgLink.replace('smart-sviewer.html', 'smart-viewer.html')
+        }
+    }
+    if (newLink) {
+        win.location.href = newLink
+    }
+}(window))

File diff suppressed because it is too large
+ 10663 - 0
public/static/ext/swiper/swiper.js


File diff suppressed because it is too large
+ 13 - 0
public/static/ext/swiper/swiper.min.css


File diff suppressed because it is too large
+ 14 - 0
public/static/ext/swiper/swiper.min.js


+ 52 - 0
scripts/update-i18n.js

@@ -0,0 +1,52 @@
+const fs = require('fs')
+const path = require('path')
+const locales = ['en', 'ja']
+
+const merge = (source, resource, target) => {
+    for (let key in source) {
+        if (typeof source[key] === 'string') {
+            target[key] = resource[key] == void 0 ? source[key] : resource[key]
+        } else {
+            target[key] = {}
+            resource[key] = resource[key] || {}
+            merge(source[key], resource[key], target[key])
+        }
+    }
+}
+
+const combine = (source, resource, target) => {
+    merge(source, resource, target)
+}
+
+function exec() {
+    var source = null
+    fs.readFile(path.join(__dirname, '..', 'src', 'locales', 'zh.json'), (err, data) => {
+        if (err) {
+            return
+        }
+        source = JSON.parse(data.toString())
+        fs.writeFile(path.join(__dirname, '..', 'public', '__langs', 'locales', 'zh.json'), JSON.stringify(source, null, 4), () => {})
+
+        locales.forEach(locale => {
+            try {
+                fs.readFile(path.join(__dirname, '..', 'src', 'locales', locale + '.json'), (err, response) => {
+                    fs.readFile(path.join(__dirname, '..', 'src', 'locales', 'zh.json'), (err, data) => {
+                        if (err) {
+                            return
+                        }
+                        var target = {}
+                        // var source = JSON.parse(data.toString())
+                        var resource = JSON.parse(response.toString())
+                        combine(source, resource, target)
+
+                        fs.writeFile(path.join(__dirname, '..', 'src', 'locales', locale + '.json'), JSON.stringify(target, null, 4), () => {})
+                        fs.writeFile(path.join(__dirname, '..', 'public', '__langs', 'locales', locale + '.json'), JSON.stringify(target, null, 4), () => {})
+                    })
+                })
+            } catch (error) {
+                console.log(error)
+            }
+        })
+    })
+}
+exec()

BIN
src/assets/img/pic_bg.png


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

+ 4 - 4
src/assets/scss/_base-vars.scss

@@ -1,12 +1,12 @@
 
 :root {
   --colors-primary-fill: 255, 255, 255;
-  --colors-primary-base-fill: 0, 200, 175;
+  --colors-primary-base-fill:0, 118, 246;
   --colors-primary-base: rgb(var(--colors-primary-base-fill));
-  --colors-primary-hover: #4DD8C7;
+  --colors-primary-hover: #0076F6;
   // --colors-primary-hover: #008B7A;
-  --colors-primary-active: #008B7A;
-  --colors-primary-click: #005046;
+  --colors-primary-active:#0076F6;
+  --colors-primary-click: #0076F6;
   --colors-warn: #FA3F48;
   // --colors-color: #999;
   --colors-color: rgba(255, 255, 255, 0.7);

+ 2 - 2
src/assets/scss/components/_button.scss

@@ -69,8 +69,8 @@
     // &:active,
     &:hover {
         // opacity: 0.8;
-        // background: var(--colors-primary-hover) !important;
-        background: #4DD8C7 !important;
+        background: var(--colors-primary-hover) !important;
+        // background: #4DD8C7 !important;
     }
     &:active {
         background-color: var(--colors-primary-active) !important;

+ 116 - 0
src/components/audio/index.vue

@@ -0,0 +1,116 @@
+<template>
+    <div class="ui-audio audio" @click="clickHandler">
+        <audio loop @play="rotation" ref="audio">
+            <source :src="src" />
+        </audio>
+        <span v-for="random in randoms" :style="{ '--percent': random }" />
+    </div>
+</template>
+
+<script setup>
+import { defineProps, ref, watchEffect, defineExpose } from 'vue'
+defineProps({
+    src: String,
+    autoplay: {
+        type: Boolean,
+        default: true,
+    },
+})
+const audio = ref()
+const randoms = ref([1, 0.5, 1, 0.5])
+const playIng = ref(false)
+
+let timeout
+const rotation = () => {
+    if (!playIng.value) return
+    for (let i = 0; i < randoms.value.length; i++) {
+        randoms.value[i] = Math.random()
+    }
+    timeout = setTimeout(rotation, 200)
+}
+
+watchEffect(() => {
+    if (audio.value) {
+        if (playIng.value) {
+            audio.value.play()
+        } else {
+            audio.value.pause()
+        }
+        clearTimeout(timeout)
+        rotation()
+    }
+})
+
+const clickHandler = () => {
+    playIng.value = !playIng.value
+}
+
+defineExpose({
+    play() {
+        playIng.value = true
+    },
+    pause() {
+        playIng.value = false
+    },
+})
+// 播放音乐
+const audioAutoPlay = () => {
+    if (window.WeixinJSBridge) {
+        WeixinJSBridge.invoke(
+            'getNetworkType',
+            {},
+            function (e) {
+                audio.value.play()
+            },
+            false
+        )
+    } else {
+        document.addEventListener(
+            'touchstart',
+            function () {
+                WeixinJSBridge.invoke('getNetworkType', {}, function (e) {
+                    audio.value.play()
+                })
+            },
+            false
+        )
+    }
+}
+const detectWeixin = () => {
+    //微信 包括PC的微信
+    return window.navigator.userAgent.toLowerCase().match(/MicroMessenger/i) == 'micromessenger'
+}
+if (detectWeixin()) {
+    console.log('detectWeixin')
+    audioAutoPlay()
+}
+// 自动播放
+clickHandler()
+</script>
+
+<script>
+export default { name: 'ui-audio' }
+</script>
+<style lang="scss" scoped>
+.audio {
+    display: inline-block;
+    cursor: pointer;
+
+    > span {
+        --height: 18px;
+        width: 3px;
+        height: calc(var(--height) * var(--percent));
+        background: var(--colors-primary-base);
+        display: inline-block;
+        transition: height 0.2s linear;
+
+        &:not(:last-child) {
+            margin-right: 2px;
+        }
+    }
+
+    audio {
+        display: none;
+    }
+}
+</style>

+ 47 - 0
src/components/button/index.vue

@@ -0,0 +1,47 @@
+<template>
+    <button class="ui-button" :class="className" :style="style">
+        <!-- <UIIcon :type="icon" v-if="icon" class="ui-button-icon" /> -->
+        <slot></slot>
+    </button>
+</template>
+
+<script setup>
+import { defineProps, computed } from 'vue'
+import { normalizeUnitToStyle } from '@/utils/dom.js'
+// import UIIcon from '../icon'
+
+const props = defineProps({
+    type: {
+        type: String,
+        default: 'normal',
+    },
+    color: {
+        type: String,
+    },
+    width: {
+        type: [String, Number],
+    },
+    icon: {
+        type: String,
+    },
+})
+
+const custom = `customize`
+const className = computed(() => (props.color ? custom : props.type))
+
+const style = computed(() => {
+    const style = {
+        width: normalizeUnitToStyle(props.width),
+    }
+
+    if (className.value === custom) {
+        style['--color'] = props.color
+    }
+    return style
+})
+</script>
+
+<script>
+export default { name: 'ui-button' }
+</script>
+

+ 24 - 13
src/components/calendar/mobile.vue

@@ -1,17 +1,28 @@
 <template>
     <div class="calendar" v-show="!showCalendar">
         <span class="prev" @click="emits('prev')"><i class="iconfont icon-arrows_left"></i></span>
-        <span class="cale" @click="onPickDate()">{{ date }}<i style="display: none;" class="iconfont icon-date"></i></span>
+        <span class="cale" @click="onPickDate()">{{ date }}<i style="display: none" class="iconfont icon-date"></i></span>
         <span class="next" @click="emits('next')"><i class="iconfont icon-arrows_right"></i></span>
     </div>
     <div class="calendar-list" v-if="showCalendar" @click="showCalendar = false">
         <div @click.stop>
-            <van-datetime-picker v-model="props.value" type="date" title="选择时间" confirm-button-text="确定" :filter="onFilter" :formatter="onFormatter" @confirm="onConfirm" @cancel="onCancel" />
+            <van-datetime-picker
+                v-model="props.value"
+                type="date"
+                :title="$t('components.chooseTime')"
+                :confirm-button-text="$t('common.confirm')"
+                :filter="onFilter"
+                :formatter="onFormatter"
+                @confirm="onConfirm"
+                @cancel="onCancel"
+            />
         </div>
     </div>
 </template>
 <script setup>
 import { ref, defineProps, computed } from 'vue'
+import { useI18n, getLocale } from '@/i18n'
+const { t } = useI18n({ useScope: 'global' })
 const props = defineProps({
     value: Date,
     highlighted: Array,
@@ -30,34 +41,34 @@ const onPickDate = () => {
 }
 const onFormatter = (type, val) => {
     if (type === 'year') {
-        return `${val}年`
+        return val + t('components.year')
     }
     if (type === 'month') {
-        return `${val}月`
+        return val + t('components.month')
     }
     if (type === 'day') {
-        return `${val}日`
+        return val + t('components.day')
     }
     return val
 }
-const onFilter = (type, options)=>{
+const onFilter = (type, options) => {
     const days = props.highlighted
-    if(!days.year.length){
+    if (!days.year.length) {
         return options
     }
-    if(type == 'year') {
+    if (type == 'year') {
         return days.year
-    }else if(type == 'month'){
+    } else if (type == 'month') {
         return days.month
-    } else if(type == 'day'){
+    } else if (type == 'day') {
         return days.day
     }
     return options
 }
-const onCancel = ()=>{
+const onCancel = () => {
     showCalendar.value = false
 }
-const onConfirm = payload=>{
+const onConfirm = payload => {
     emits('selected', { payload })
     showCalendar.value = false
 }
@@ -99,7 +110,7 @@ const onConfirm = payload=>{
     bottom: 0;
     height: 100vh;
     z-index: 1000;
-    background: rgba(0,0,0,0.5);
+    background: rgba(0, 0, 0, 0.5);
     display: flex;
     align-items: flex-end;
     justify-content: center;

+ 1 - 1
src/components/datepicker/PickerDay.vue

@@ -16,7 +16,7 @@
           >&lt;</span
         >
         <span class="day__month_btn" @click="showMonthCalendar" :class="allowedToShowView('month') ? 'up' : ''"
-          >{{ isYmd ? currMonthName : currYearName }} {{ isYmd ? currYearName : currMonthName }}</span
+          >{{ isYmd ? currMonthName : currYearName }}{{$t('components.year')}} {{ isYmd ? currYearName : currMonthName }}</span
         >
         <span @click="isRtl ? previousMonth() : nextMonth()" class="next" :class="{ disabled: isRightNavDisabled }"
           >&gt;</span

+ 14 - 5
src/components/dialog/Confirm.vue

@@ -17,9 +17,12 @@
     </ui-dialog>
 </template>
 <script>
-import { defineComponent } from 'vue'
+import { defineComponent, defineEmits } from 'vue'
 import { isFunction, omit } from '../../utils'
-
+import UiDialog from './Dialog.vue'
+import UiButton from '../button/index.vue'
+// console.error(UiDialog)
+const emits = defineEmits(['ok', 'no'])
 export default defineComponent({
     name: 'ui-confirm',
     props: {
@@ -49,15 +52,21 @@ export default defineComponent({
     },
     setup: function (props, ctx) {
         const close = result => {
-            if (isFunction(props.func) && props.func(result) === false) {
-                return
+            if (result == 'ok') {
+                ctx.emit('ok', 'ok')
+            } else {
+                ctx.emit('no', 'no')
             }
-            isFunction(props.destroy) && props.destroy()
+            // if (isFunction(props.func) && props.func(result) === false) {
+            //     return
+            // }
+            // isFunction(props.destroy) && props.destroy()
         }
         return {
             ...omit(props, 'destroy', 'func'),
             close,
         }
     },
+    components: { UiDialog, UiButton },
 })
 </script>

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

@@ -0,0 +1,324 @@
+<template>
+    <div class="tag-editor">
+        <div class="tag-editor-content" :style="{ height: height + 'px' }">
+            <header>
+                <span>{{ $t('tag.creatTag') }}</span>
+                <i class="iconfont icon-close" @click="onClose"></i>
+            </header>
+            <article>
+                <div>
+                    <h4><span>*</span>{{ $t('tag.tagName') }}</h4>
+                    <UiInput v-model="form.title" type="text" :placeholder="$t('tag.inputTagName')" :maxlength="20" />
+                </div>
+                <div>
+                    <h4><span>*</span>{{ $t('tag.status') }}</h4>
+                    <UiInput v-model="form.status" type="select" :placeholder="$t('tag.inputStatus')" :data="data.status" />
+                </div>
+                <div>
+                    <h4>{{ $t('tag.member') }}</h4>
+                    <UiSelectList v-model="form.members" :placeholder="$t('tag.inputMember')" :data="data.members" />
+                </div>
+                <div>
+                    <h4>{{ $t('tag.desc') }}</h4>
+                    <UiArea v-model="form.describe" type="text" :placeholder="$t('tag.inputDesc')" :maxlength="50" />
+                </div>
+                <div>
+                    <h4>{{ $t('tag.uploadFile') }}</h4>
+                    <UiMedias />
+                </div>
+            </article>
+            <footer>
+                <button @click="onSubmit">{{ $t('common.submit') }}</button>
+            </footer>
+        </div>
+        <Toast v-if="showTips" type="warn" :content="showTips" :close="() => (showTips = null)" />
+        <Loading v-if="showLoading"></Loading>
+    </div>
+</template>
+<script setup>
+import { ref, inject, onMounted, onBeforeUnmount, watch } from 'vue'
+import { convertBlob2File } from '@/utils/file'
+import { http } from '@/utils/request'
+import browser from '@/utils/browser'
+import Toast from '@/components/dialog/Toast'
+import Loading from '@/components/loading/Loading.vue'
+import UiArea from '../form/Area.vue'
+import UiInput from '../form/Input.vue'
+import UiMedias from '../form/medias'
+import UiSelectList from '../form/SelectList.vue'
+import { from } from 'readable-stream'
+import i18n from '@/i18n'
+const { t } = i18n.global
+const showLoading = ref(false)
+const showTips = ref(null)
+const projectId = browser.valueFromUrl('projectId') || 1
+const notify = inject('notify')
+const tags = inject('tags')
+const isEdit = inject('isEdit')
+const height = ref(0)
+const form = ref({
+    title: '',
+    describe: '',
+    status: '',
+    members: [],
+})
+const typeList = ['image', 'video', 'audio', 'link']
+let mediaList = []
+let tag = null
+const data = ref({
+    status: [
+        { text: t('tag.statusText1'), value: 1 },
+        { text: t('tag.statusText2'), value: 2 },
+        { text: t('tag.statusText3'), value: 3 },
+        { text: t('tag.statusText4'), value: 4 },
+    ],
+    members: [],
+})
+const onClose = () => {
+    if (notify.value.__temp) {
+        if (window.kankan) {
+            kankan.TagManager.remove(notify.value.sid)
+        } else {
+            tags.value.splice(tags.value.length - 1, 1)
+        }
+    }
+
+    isEdit.value = false
+    notify.value = null
+}
+let pushData = null
+const onSubmit = async () => {
+    if (!form.value.title) {
+        return (showTips.value = t('tag.inputTagName'))
+    }
+    if (!form.value.status) {
+        return (showTips.value = t('tag.inputStatus'))
+    }
+    notify.value.title = form.value.title
+    notify.value.content = form.value.describe
+    //提交前删除别的数据
+    typeList.forEach(item => {
+        if (notify.value.type != item) {
+            delete notify.value.media[item]
+        }
+    })
+    if (notify.value._add) {
+        pushData = notify.value
+    }
+    tag = JSON.stringify(notify.value, (key, value) => {
+        if (key === 'visiblePanos') {
+            return value.map(item => item.id)
+        }
+
+        if (key === 'media' && notify.value.media[notify.value.type]) {
+            mediaList = notify.value.media[notify.value.type]
+        }
+
+        return value
+    })
+    tag = JSON.parse(tag)
+    tag.status = form.value.status
+    tag.members = form.value.members.map(item => item.value)
+    delete tag.__temp
+    await handlerUpload(tag)
+}
+const handlerUpload = async data => {
+    showLoading.value = true
+    if (mediaList.length) {
+        for (let i = 0; i < mediaList.length; i++) {
+            if (mediaList[i].file) {
+                let res = await http.postFile(`smart-site/upload/${projectId}/${notify.value.type}/file/`, {
+                    file: mediaList[i].file,
+                })
+                if (res.success) {
+                    delete tag.media[notify.value.type][i].file
+                    delete notify.value.media[notify.value.type][i].file
+                    tag.media[notify.value.type][i].src = res.data
+                    notify.value.media[notify.value.type][i].src = res.data
+                } else if (res.code == 4008) {
+                    showTips.value = t('code.4008')
+                } else {
+                    showTips.value = res.message
+                }
+            }
+        }
+
+        mediaList = []
+    }
+    let params = {
+        markingId: null,
+        projectId,
+        userIds: tag.members,
+        markingStatus: form.value.status,
+        markingTitle: form.value.title,
+        hotData: tag,
+        num: browser.getURLParam('m'),
+    }
+    if (tag.id) {
+        params.markingId = tag.id
+    }
+
+    http.post(`smart-site/marking/addOrUpdate`, params).then(response => {
+        showLoading.value = false
+        if (response.success) {
+            notify.value.status = tag.status
+            notify.value.members = tag.members
+            if (pushData && notify.value._add) {
+                delete notify.value._add
+                pushData.status = tag.status
+                pushData.content = tag.content
+                pushData.members = tag.members
+                pushData.createTime = response.data.createTime
+              
+                tags.value.push(pushData)
+                pushData = null
+            }
+            isEdit.value = false
+            delete notify.value.__temp
+            notify.value.id = response.data.markingId
+            notify.value = null
+        } else if (response.code == 4008) {
+            showTips.value = t('code.4008')
+        } else {
+            showTips.value = response.message
+        }
+    })
+}
+const onResize = () => {
+    height.value = window.innerHeight - 90
+}
+let markingId = null
+onMounted(() => {
+    http.post(`smart-site/projectTeam/select`, { projectId }).then(response => {
+        data.value.members = response.data.map(item => {
+            return {
+                text: item.nickName,
+                value: item.userId,
+            }
+        })
+
+        if (notify.value) {
+            form.value.status = String(notify.value.status || '')
+            form.value.title = notify.value.title || ''
+            form.value.describe = notify.value.content || ''
+            form.value.members = []
+            if (notify.value.members && notify.value.members.length) {
+                notify.value.members.forEach(item => {
+                    let find = data.value.members.find(c => c.value == item)
+                    if (find) {
+                        form.value.members.push(find)
+                    }
+                })
+            }
+        }
+    })
+
+    if (window.kankan) {
+        window.kankan.TagManager.focusTag(notify.value.sid, {
+            direction: 'left',
+            attrs: {
+                width: 0,
+                height: 400,
+            },
+        })
+    } else if (window.laser) {
+        window.laser.then(sdk => {
+            // let pos = notify.value.tag.position
+            let pos = notify.value.position
+            sdk.scene.comeToTag(new THREE.Vector3(pos.x, pos.y, pos.z))
+        })
+    }
+    onResize()
+    window.addEventListener('resize', onResize)
+})
+
+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>

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

@@ -0,0 +1,149 @@
+<template>
+    <div
+        v-if="props.tag"
+        @click="onClick"
+        :data-tag-id="props.tag.sid"
+        :style="{ 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>
+import { inject } from 'vue'
+const props = defineProps({
+    tag: {
+        type: Object,
+        required: true,
+    },
+    index: {
+        type: Number,
+        required: true,
+    },
+})
+
+const notify = inject('notify')
+const onClick = () => {
+    notify.value = props.tag //{ 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>

+ 52 - 0
src/components/files/TagManager.vue

@@ -0,0 +1,52 @@
+<template>
+    <div xui_tags_view>
+        <TagItem v-for="(tag, index) in tags" :tag="tag" :index="index + 1" @action="onAction" :class="{ active: notify && notify.sid == tag.sid }" />
+        <TagEditor v-if="notify && isEdit" :notify="notify" @action="onAction" />
+        <TagView v-if="notify && !isEdit" :notify="notify" />
+    </div>
+</template>
+<script setup>
+import { inject, watch, computed } from 'vue'
+import TagItem from './TagItem.vue'
+import TagEditor from './TagEditor.vue'
+import TagView from './TagView.vue'
+
+let timer = setInterval(() => {
+    if (window.kankan) {
+        init(window.kankan)
+        clearInterval(timer)
+    } else if (window.laser) {
+        initLaserTag()
+        clearInterval(timer)
+    }
+}, 50)
+
+const notify = inject('notify')
+const tags = inject('tags')
+const isEdit = inject('isEdit')
+const init = sdk => {
+    // sdk.TagManager.load(tags)
+}
+
+const initLaserTag = () => {
+    window.laser.then(sdk => {
+        sdk.scene.on('posChange', cameraPos => {
+            tags.value.forEach(tag => {
+                const info2d = sdk.scene.getScreenByPoint(tag.position)
+                tag.x = info2d.pos.x
+                tag.y = info2d.pos.y
+                tag.visible = info2d.trueSide
+            })
+        })
+    })
+}
+</script>
+<style lang="scss" scoped>
+[xui_tags_view] {
+    position: absolute;
+    pointer-events: none;
+    width: 100%;
+    height: 100%;
+    z-index: 1000;
+}
+</style>

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

@@ -0,0 +1,335 @@
+<template>
+    <div class="tag-view" v-show="canShow">
+        <!-- <div class="tag-view-content" :style="{ height: height + 'px' }"> -->
+        <div class="tag-view-content">
+            <header>
+                <span>{{ notify.title }}</span>
+                <!-- <i class="iconfont icon-close" @click="emits('action', null)"></i> -->
+                <i class="iconfont icon-close" @click="onClose"></i>
+            </header>
+            <article>
+                <TagMsg @setShow="setShow"></TagMsg>
+                <Comment :slideHeigt="slideHeigt"></Comment>
+            </article>
+            <!-- <footer></footer> -->
+        </div>
+    </div>
+</template>
+<script setup>
+import { ref, onMounted, onBeforeUnmount, computed, inject } from 'vue'
+
+import TagMsg from './content/TagMsg.vue'
+import Comment from './content/Comment.vue'
+import { nextTick } from 'process'
+const notify = inject('notify')
+const emits = defineEmits(['action'])
+const height = ref(0)
+const canShow = ref(false)
+const onResize = () => {
+    height.value = window.innerHeight - 90
+}
+const onClose = () => {
+    if (window.kankan) {
+        if (notify.value.__temp) {
+            kankan.TagManager.remove(notify.value.sid)
+        }
+    } else {
+    }
+    notify.value = null
+}
+const slideHeigt = ref(0)
+const setShow = data => {
+    canShow.value = true
+
+    nextTick(() => {
+        slideHeigt.value = document.querySelector('.left-item').offsetHeight
+    })
+}
+onMounted(() => {
+    if (window.kankan) {
+        window.kankan.TagManager.focusTag(notify.value.sid, {
+            direction: 'left',
+            attrs: {
+                width: 0,
+                // height: 400,
+            },
+        })
+    } else if (window.laser) {
+        window.laser.then(sdk => {
+            let pos = notify.value.position
+            sdk.scene.comeToTag(new THREE.Vector3(pos.x, pos.y, pos.z))
+        })
+    }
+    onResize()
+    window.addEventListener('resize', onResize)
+})
+
+onBeforeUnmount(() => {
+    window.removeEventListener('resize', onResize)
+})
+</script>
+<style lang="scss" scoped>
+.aside-item {
+    padding: 20px;
+    box-sizing: border-box;
+    line-height: 28px;
+    .content-item {
+        margin-bottom: 20px;
+        // &:last-of-type {
+        //     margin-bottom: 0;
+        // }
+    }
+    // &.left-item {
+    //     width: 400px;
+    //     .item-title {
+    //         font-size: 14px;
+    //         color: #999;
+    //     }
+    //     .content-desc {
+    //         font-size: 14px;
+    //         line-height: 28px;
+    //     }
+    //     .media-box {
+    //         width: 360px;
+    //         height: 225px;
+    //         border-radius: 4px 4px 4px 4px;
+    //         opacity: 1;
+    //         border: 1px solid rgba(255, 255, 255, 0.2);
+    //         position: relative;
+    //         // margin-bottom: 30px;
+    //         // &.zoom-in {
+    //         //     cursor: zoom-in;
+    //         // }
+    //     }
+    // }
+
+    &.right-item {
+        position: relative;
+        .input-content {
+            width: 100%;
+            height: 34px;
+            margin: 20px 0;
+            padding: 0 20px;
+            position: absolute;
+            bottom: 0;
+            left: 0;
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            .input-box {
+                width: 226px;
+                height: 34px;
+                background: rgba(255, 255, 255, 0.1);
+                border-radius: 4px;
+                opacity: 1;
+                border: 1px solid rgba(255, 255, 255, 0.2);
+                input {
+                    line-height: 34px;
+                    padding: 0 10px;
+                    color: #fff;
+                }
+            }
+            .send-btn {
+                width: 60px;
+                height: 34px;
+                background: #0076f6;
+                border-radius: 4px;
+                opacity: 1;
+                text-align: center;
+                line-height: 34px;
+                cursor: pointer;
+            }
+        }
+        .comment-content {
+            .comment-header {
+                font-size: 16px;
+                font-weight: bold;
+                color: #999;
+            }
+            .comment-msg {
+                .comment-item {
+                    display: flex;
+                    align-items: flex-start;
+                    justify-content: flex-start;
+                    margin-top: 14px;
+                    .avatar-box {
+                        width: 24px;
+                        height: 24px;
+                        border-radius: 50%;
+                        background: #f2f2f2;
+                        margin-right: 6px;
+                    }
+                    .comment-box {
+                        flex: 1;
+                        > div {
+                            display: flex;
+                            align-items: center;
+                            justify-content: space-between;
+                        }
+                        .view-top {
+                            .user-name {
+                                font-size: 14px;
+                                color: #999;
+                            }
+                            .iconfont {
+                                color: #999;
+                                font-size: 1em;
+                                cursor: pointer;
+                            }
+                        }
+                        .view-middle {
+                            .comment-text {
+                                font-size: 14px;
+                                color: #fff;
+                            }
+                        }
+                        .view-bottom {
+                            font-size: 12px;
+                            .comment-time {
+                                color: #999;
+                            }
+                            .reply-btn {
+                                color: #0076f6;
+                                cursor: pointer;
+                            }
+                        }
+                        .reply-content {
+                            .reply-item {
+                                display: flex;
+                                align-items: flex-start;
+                                justify-content: flex-start;
+                                margin-top: 14px;
+                                width: 100%;
+                                .avatar-box {
+                                    width: 24px;
+                                    height: 24px;
+                                    border-radius: 50%;
+                                    background: #f2f2f2;
+                                    margin-right: 6px;
+                                }
+                                .reply-box {
+                                    flex: 1;
+                                    > div {
+                                        display: flex;
+                                        align-items: center;
+                                        justify-content: space-between;
+                                    }
+                                    .view-top {
+                                        .user-name {
+                                            font-size: 14px;
+                                            color: #999;
+                                        }
+                                        .iconfont {
+                                            color: #999;
+                                            font-size: 1em;
+                                            cursor: pointer;
+                                        }
+                                    }
+                                    .view-middle {
+                                        .reply-text {
+                                            font-size: 14px;
+                                            color: #fff;
+                                            .reply-tips {
+                                                color: #999;
+                                            }
+                                        }
+                                    }
+                                    .view-bottom {
+                                        font-size: 12px;
+                                        .reply-time {
+                                            color: #999;
+                                        }
+                                        .reply-btn {
+                                            color: #0076f6;
+                                            cursor: pointer;
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+.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;
+    width: 741px;
+    max-height: 776px;
+    min-height: 70px;
+    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%;
+        // max-height: 711px;
+        overflow: hidden;
+        > div {
+            width: 50%;
+            
+            // &.left-item {
+            //     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>

+ 452 - 0
src/components/files/content/Comment.vue

@@ -0,0 +1,452 @@
+<!--  -->
+<template>
+    <div class="aside-item right-item">
+        <div class="comment-content" ref="slider" v-if="slideHeigt" :style="`height:${slideHeigt - 84}px;`">
+            <div class="comment-header">
+                <span>{{ $t('tag.comment') }}</span>
+            </div>
+            <div class="comment-msg">
+                <div class="comment-item" v-for="(i, index) in commentList">
+                    <div class="avatar-box" :style="i.head ? `background-image:url(${i.head});` : `background-image:url(${emptyAvatar});`"></div>
+                    <div class="comment-box">
+                        <div class="view-box view-top">
+                            <span class="user-name">{{ i.nickName || $t('tag.unkownUser') }}</span>
+                            <i class="iconfont icon-del" v-if="i.userId == userId" @click="delComment({ commentId: i.commentId, index })"></i>
+                        </div>
+                        <div class="view-box view-middle">
+                            <span class="comment-text">{{ i.content }}</span>
+                        </div>
+                        <div class="view-box view-bottom">
+                            <span class="comment-time">{{ i.createTime }}</span>
+                            <span class="reply-btn" @click="handlerReply({ parentId: i.commentId, nickName: i.nickName }, index)">{{ $t('tag.reply') }}</span>
+                        </div>
+                        <div class="reply-content" v-if="i.children">
+                            <div class="reply-item" v-for="(j, j_index) in i.children">
+                                <div class="avatar-box" :style="j.head ? `background-image:url(${j.head});` : `background-image:url(${emptyAvatar});`"></div>
+                                <div class="reply-box">
+                                    <div class="view-box view-top">
+                                        <span class="user-name">{{ j.nickName || $t('tag.unkownUser') }}</span>
+                                        <i class="iconfont icon-del" v-if="j.userId == userId" @click="delComment({ commentId: j.commentId, index: j_index, parentIndex: index })"></i>
+                                    </div>
+                                    <div class="view-box view-middle">
+                                        <span class="reply-text"
+                                            ><span v-if="j.replyId"
+                                                >{{ $t('tag.reply') }}<span class="reply-tips">@{{ j.replyNickName || $t('tag.unkownUser') }}</span></span
+                                            >
+                                            {{ j.content }}</span
+                                        >
+                                    </div>
+                                    <div class="view-box view-bottom">
+                                        <span class="reply-time">{{ j.createTime }}</span>
+                                        <span class="reply-btn" @click="handlerReply({ parentId: i.commentId, replyId: j.commentId, parentUserId: j.userId, nickName: j.nickName }, j_index)">{{
+                                            $t('tag.reply')
+                                        }}</span>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="empty-box" v-if="!commentList.length">
+                <div class="pic"></div>
+                <div>{{ $t('tag.noComment') }}</div>
+            </div>
+        </div>
+
+        <div class="input-content">
+            <div class="input-box">
+                <input ref="input$" @input="handlerInput" v-model="inputText" :placeholder="placeholderText" type="text" :maxlength="commentMaxLength" />
+                <div class="maxlength">
+                    <span>{{ inputText.length }}</span
+                    >&nbsp;/&nbsp;{{ commentMaxLength }}
+                </div>
+            </div>
+            <div class="send-btn" @click="hanlderSubmit">{{ $t('common.publish') }}</div>
+        </div>
+    </div>
+    <Toast v-if="showTips" :type="tipsType" :content="showTips" :close="() => (showTips = null)" />
+
+    <ui-confirm v-if="delComfirm" :title="$t('common.tips')" :noText="$t('common.cancel')" :okText="$t('common.confirm')" @ok="handlerDel" @no="handlerDel">
+        <template #content>
+            <div>{{ $t('tag.deletetCommentTips') }}</div>
+        </template>
+    </ui-confirm>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount, computed, inject, nextTick, defineProps } from 'vue'
+import Toast from '@/components/dialog/Toast'
+import { http } from '@/utils/request'
+import avatar from '@/assets/img/avatar@2x.png'
+import UiConfirm from '@/components/dialog/Confirm.vue'
+import i18n from '@/i18n'
+import UiInput from '../../form/Input.vue'
+const { t } = i18n.global
+const props = defineProps({
+    slideHeigt: Number,
+})
+const commentMaxLength = ref(200)
+let canPut = true
+const delComfirm = ref(null)
+const emptyAvatar = ref(avatar)
+const notify = inject('notify')
+const emits = defineEmits(['action'])
+const input$ = ref(null)
+const inputText = ref('')
+const placeholderText = ref(t('tag.addComment'))
+const replyInfo = ref(null)
+const tipsType = ref('warn')
+const showTips = ref(null)
+const slider = ref(null)
+
+const handlerReply = (data, index) => {
+    inputText.value = ''
+    let name = data.nickName ? data.nickName : t('tag.unkownUser')
+    placeholderText.value = '@' + name
+    delete data.nickName
+    replyInfo.value = data
+}
+
+const handlerInput = () => {
+    // console.log(inputText.value.length)
+    // if (replyInfo.value && inputText.value.length) {
+    //     // console.log(1)
+    // }
+}
+let parentId = null
+let commentId = null
+const userId = ref(localStorage.getItem('userId') - 0)
+const commentList = ref([])
+const hanlderSubmit = () => {
+    if (inputText.value == '') {
+        tipsType.value = 'warn'
+        showTips.value = t('tag.addCommentTips')
+        return
+    }
+
+    let params = {
+        markingId: notify.value.id,
+        content: inputText.value,
+        userId: userId.value,
+        parentId: parentId,
+    }
+
+    if (replyInfo.value) {
+        for (let key in replyInfo.value) {
+            if (replyInfo.value[key]) {
+                params[key] = replyInfo.value[key]
+            }
+        }
+    }
+
+    if (canPut) {
+        canPut = false
+        http.post(`smart-site/comment/reply`, params)
+            .then(response => {
+                if (response.success) {
+                    getAllComments()
+
+                    // if (replyInfo.value) {
+                    // } else {
+                    //     // slider.value.
+                    //     slider.value.scrollTo({
+                    //         top: 0,
+                    //         behavior: 'smooth',
+                    //     })
+                    // }
+
+                    replyInfo.value = null
+                    inputText.value = ''
+                    placeholderText.value = t('tag.addComment')
+                } else {
+                    tipsType.value = 'error'
+                    showTips.value = response.message
+                }
+                canPut = true
+            })
+            .catch(err => {
+                canPut = true
+            })
+    }
+}
+const onClose = () => {
+    if (window.kankan) {
+        if (notify.value.__temp) {
+            kankan.TagManager.remove(notify.value.sid)
+        }
+    } else {
+    }
+    notify.value = null
+}
+const getAllComments = () => {
+    http.post(`smart-site/comment/tree/all`, { markingId: notify.value.id }).then(response => {
+        if (response.success) {
+            commentList.value = response.data
+        } else {
+        }
+    })
+}
+const handlerDel = status => {
+    if (status == 'ok') {
+        http.post(`smart-site/comment/del`, { commentId: delComfirm.value.commentId }).then(response => {
+            if (response.success) {
+                // if (!delComfirm.value.parentIndex) {
+                //     commentList.value.splice(delComfirm.value.index, 1)
+                // } else {
+                //     commentList.value[delComfirm.value.parentIndex].children.splice(delComfirm.value.index, 1)
+                // }
+                getAllComments()
+                if (replyInfo.value?.parentId == delComfirm.value.commentId) {
+                    replyInfo.value = null
+                    inputText.value = ''
+                    placeholderText.value = t('tag.addComment')
+                }
+                tipsType.value = 'success'
+                showTips.value = t('common.deleteSuccess')
+            } else {
+                tipsType.value = 'error'
+                showTips.value = response.message
+            }
+            delComfirm.value = null
+        })
+    } else {
+        delComfirm.value = null
+    }
+}
+const delComment = data => {
+    delComfirm.value = data
+}
+onMounted(() => {
+    getAllComments()
+    if (window.kankan) {
+        window.kankan.TagManager.focusTag(notify.value.sid, {
+            direction: 'left',
+            attrs: {
+                width: 0,
+                // height: 400,
+            },
+        })
+    } else if (window.laser) {
+        window.laser.then(sdk => {
+            let pos = notify.value.position
+            sdk.scene.comeToTag(new THREE.Vector3(pos.x, pos.y, pos.z))
+        })
+    }
+    nextTick(() => {
+        input$.value.addEventListener('keydown', function (e) {
+            if (e.keyCode == 8) {
+                if (replyInfo.value && !inputText.value.length) {
+                    replyInfo.value = null
+                    placeholderText.value = t('tag.addComment')
+                }
+            }
+        })
+    })
+})
+
+onBeforeUnmount(() => {})
+</script>
+<style lang="scss" scoped>
+.aside-item {
+    padding: 20px 0 20px 20px;
+    box-sizing: border-box;
+    line-height: 28px;
+    flex: 1;
+    &.right-item {
+        position: relative;
+        .input-content {
+            width: 100%;
+            height: 34px;
+            margin: 20px 0;
+            padding: 0 20px;
+            position: absolute;
+            bottom: 0;
+            left: 0;
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            .input-box {
+                width: 226px;
+                height: 34px;
+                background: rgba(255, 255, 255, 0.1);
+                border-radius: 4px;
+                opacity: 1;
+                border: 1px solid rgba(255, 255, 255, 0.2);
+                position: relative;
+                input {
+                    width: 75%;
+                    line-height: 34px;
+                    padding: 0 5px;
+                    color: #fff;
+                }
+                .maxlength {
+                    position: absolute;
+                    right: 0;
+                    top: 50%;
+                    transform: translateY(-50%);
+                    white-space: nowrap;
+                    margin-top: 2px;
+                    margin-right: 0px;
+                    color: #999;
+                    span {
+                        color: #0076f6;
+                    }
+                }
+            }
+            .send-btn {
+                width: 60px;
+                height: 34px;
+                background: #0076f6;
+                border-radius: 4px;
+                opacity: 1;
+                text-align: center;
+                line-height: 34px;
+                cursor: pointer;
+            }
+        }
+        .empty-box {
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            flex-flow: column;
+            position: absolute;
+            top: 50%;
+            left: 50%;
+            transform: translate(-50%, -50%);
+            .pic {
+                width: 134px;
+                height: 134px;
+                background: url('~@/assets/img/pic_bg.png') no-repeat;
+                background-size: 100% 100%;
+            }
+            div {
+                margin-top: 5px;
+                color: #999;
+            }
+        }
+        .comment-content {
+            // height: calc(100% - 54px);
+            overflow-y: auto;
+            padding: 0 20px 0 0;
+            position: relative;
+            .comment-header {
+                font-size: 16px;
+                font-weight: bold;
+                color: #999;
+            }
+            .comment-msg {
+                .comment-item {
+                    display: flex;
+                    align-items: flex-start;
+                    justify-content: flex-start;
+                    margin-top: 14px;
+                    .avatar-box {
+                        width: 24px;
+                        height: 24px;
+                        border-radius: 50%;
+                        margin-right: 6px;
+                        background-size: 100% 100%;
+
+                        background-repeat: no-repeat;
+                    }
+                    .comment-box {
+                        flex: 1;
+                        > div.view-box {
+                            display: flex;
+                            align-items: center;
+                            justify-content: space-between;
+                        }
+                        .view-top {
+                            .user-name {
+                                font-size: 14px;
+                                color: #999;
+                            }
+                            .iconfont {
+                                color: #999;
+                                font-size: 1em;
+                                cursor: pointer;
+                            }
+                        }
+                        .view-middle {
+                            .comment-text {
+                                word-break: break-all;
+                                font-size: 14px;
+                                color: #fff;
+                            }
+                        }
+                        .view-bottom {
+                            font-size: 12px;
+                            .comment-time {
+                                color: #999;
+                            }
+                            .reply-btn {
+                                color: #0076f6;
+                                cursor: pointer;
+                            }
+                        }
+                        .reply-content {
+                            .reply-item {
+                                display: flex;
+                                align-items: flex-start;
+                                justify-content: flex-start;
+                                margin-top: 14px;
+                                width: 100%;
+                                .avatar-box {
+                                    width: 24px;
+                                    height: 24px;
+                                    border-radius: 50%;
+                                    margin-right: 6px;
+                                    background-size: 100% 100%;
+
+                                    background-repeat: no-repeat;
+                                }
+                                .reply-box {
+                                    flex: 1;
+                                    > div {
+                                        display: flex;
+                                        align-items: center;
+                                        justify-content: space-between;
+                                    }
+                                    .view-top {
+                                        .user-name {
+                                            font-size: 14px;
+                                            color: #999;
+                                        }
+                                        .iconfont {
+                                            color: #999;
+                                            font-size: 1em;
+                                            cursor: pointer;
+                                        }
+                                    }
+                                    .view-middle {
+                                        .reply-text {
+                                            font-size: 14px;
+                                            color: #fff;
+                                            word-break: break-all;
+                                            .reply-tips {
+                                                color: #0076f6;
+                                                margin: 0 2px;
+                                            }
+                                        }
+                                    }
+                                    .view-bottom {
+                                        font-size: 12px;
+                                        .reply-time {
+                                            color: #999;
+                                        }
+                                        .reply-btn {
+                                            color: #0076f6;
+                                            cursor: pointer;
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+</style>

+ 276 - 0
src/components/files/content/TagMsg.vue

@@ -0,0 +1,276 @@
+<!--  -->
+<template>
+    <div class="aside-item left-item">
+        <UiAudio v-if="notify.type == 'audio'" :src="notify.media?.[notify.type][0].src" />
+
+        <div class="content-item">
+            <div class="item-title">{{ $t('tag.createTime') }}</div>
+            <span class="content-desc">{{ notify.createTime }}</span>
+        </div>
+        <div class="content-item">
+            <div class="item-title">{{ $t('tag.creater') }}</div>
+            <span class="content-desc">{{ notify.lastCreateBy || $t('tag.unkownUser') }}</span>
+        </div>
+        <div class="content-item">
+            <div class="item-title">{{ $t('tag.status') }}</div>
+            <span class="content-desc" v-for="i in data.status">
+                <span v-if="i.value == form.status">{{ i.text }}</span></span
+            >
+        </div>
+        <div class="content-item">
+            <div class="item-title">{{ $t('tag.member') }}</div>
+            <span class="content-desc" v-for="(i, index) in form.members"
+                ><span>{{ i.text }}</span>
+                <span v-if="index < form.members.length - 1">、</span>
+            </span>
+        </div>
+        <div class="content-item">
+            <div class="item-title">{{ $t('tag.desc') }}</div>
+            <span class="content-desc">{{ form.describe }}</span>
+        </div>
+        <div class="media-box" :class="{ nor: notify.type == 'link', 'zoom-in': notify.type != 'video' }" @click="openView = true" v-if="notify.media?.[notify.type]?.length && notify.type != 'audio'">
+            <component :is="component"></component>
+        </div>
+
+        <!-- <audio preload="true" autoplay>
+            <source :src="notify.media?.[notify.type][0].src" type="audio/mpeg" />
+        </audio> -->
+        <teleport to="body">
+            <div class="mask-layer" v-if="openView">
+                <div class="close-btn">
+                    <i class="iconfont icon-close" @click="openView = false"></i>
+                </div>
+                <div class="layer-content">
+                    <component :is="component"></component>
+                </div>
+            </div>
+        </teleport>
+    </div>
+    <Toast v-if="showTips" :type="showTips?.type" :content="showTips.text" :close="closeTips" />
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount, computed, inject } from 'vue'
+import { http } from '@/utils/request'
+import browser from '@/utils/browser'
+import Image from '@/components/form/medias/Image.vue'
+import Video from '@/components/form/medias/Video.vue'
+import Audio from '@/components/form/medias/Audio.vue'
+import Link from '@/components/form/medias/Link.vue'
+import UiAudio from '@/components/audio/index.vue'
+import Toast from '@/components/dialog/Toast'
+import { nextTick } from 'process'
+import i18n from '@/i18n'
+const { t } = i18n.global
+const projectId = browser.valueFromUrl('projectId') || 1
+const notify = inject('notify')
+// const props = defineProps(['notify'])
+const emits = defineEmits(['action', 'setShow'])
+const height = ref(0)
+const form = ref({
+    title: '',
+    describe: '',
+    status: '',
+    members: [],
+})
+const openView = ref(false)
+const data = ref({
+    status: [
+        { text: t('tag.statusText1'), value: 1 },
+        { text: t('tag.statusText2'), value: 2 },
+        { text: t('tag.statusText3'), value: 3 },
+        { text: t('tag.statusText4'), value: 4 },
+    ],
+    members: [],
+})
+const showTips = ref(null)
+const media = computed(() => {
+    return notify.value.type || 'image'
+})
+const component = computed(() => {
+    switch (media.value) {
+        case 'image':
+            return Image
+        case 'video':
+            return Video
+        case 'audio':
+            return Audio
+        case 'link':
+            return Link
+    }
+})
+
+const onClose = () => {
+    if (window.kankan) {
+        if (notify.value.__temp) {
+            kankan.TagManager.remove(notify.value.sid)
+        }
+    } else {
+    }
+    notify.value = null
+}
+const closeTips = () => {
+    notify.value = null
+    showTips.value = null
+}
+onMounted(() => {
+    http.post(`smart-site/projectTeam/select`, { projectId }).then(response => {
+        if (response.success) {
+            data.value.members = response.data.map(item => {
+                return {
+                    text: item.nickName,
+                    value: item.userId,
+                }
+            })
+
+            if (notify.value) {
+                form.value.status = String(notify.value.status || '')
+                form.value.title = notify.value.title || ''
+                form.value.describe = notify.value.content || ''
+                form.value.members = []
+                if (notify.value.members && notify.value.members.length) {
+                    notify.value.members.forEach(item => {
+                        let find = data.value.members.find(c => c.value == item)
+                        if (find) {
+                            form.value.members.push(find)
+                        }
+                    })
+                }
+            }
+            nextTick(() => {
+                emits('setShow')
+            })
+        } else {
+            if (response.code == 4008) {
+                showTips.value = {
+                    type: 'warn',
+                    text: t('code.4008'),
+                }
+            } else {
+                showTips.value = {
+                    type: 'warn',
+                    text: response.message,
+                }
+            }
+        }
+    })
+
+    if (window.kankan) {
+        window.kankan.TagManager.focusTag(notify.value.sid, {
+            direction: 'left',
+            attrs: {
+                width: 0,
+                // height: 400,
+            },
+        })
+    } else if (window.laser) {
+        window.laser.then(sdk => {
+            let pos = notify.value.position
+            sdk.scene.comeToTag(new THREE.Vector3(pos.x, pos.y, pos.z))
+        })
+    }
+})
+
+onBeforeUnmount(() => {})
+</script>
+<style lang="scss">
+.mask-layer {
+    .swiper-button-prev,
+    .swiper-button-next {
+        width: 48px !important;
+        height: 48px !important;
+        background: rgba(0, 0, 0, 0.5) !important;
+        color: #fff;
+        &::after {
+            font-size: 18px !important;
+        }
+    }
+}
+</style>
+<style lang="scss" scoped>
+.ui-audio {
+    position: absolute;
+    right: 20px;
+    top: 20px;
+}
+.mask-layer {
+    width: 100%;
+    height: 100%;
+    position: fixed;
+    top: 0;
+    left: 0;
+    background: #292929;
+    z-index: 10000;
+    .layer-content {
+        width: 90%;
+        height: 80%;
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+    }
+    .close-btn {
+        position: absolute;
+        top: 20px;
+        right: 60px;
+        cursor: pointer;
+        z-index: 100;
+        i {
+            color: #fff;
+            font-size: 24px;
+        }
+    }
+}
+.aside-item {
+    padding: 20px;
+    box-sizing: border-box;
+    line-height: 28px;
+    position: relative;
+    .content-item {
+        margin-bottom: 20px;
+        &:last-of-type {
+            margin-bottom: 0;
+        }
+    }
+    &.left-item {
+        width: 400px;
+        border-right: solid 1px rgba(255, 255, 255, 0.16);
+        .item-title {
+            font-size: 14px;
+            color: #999;
+        }
+        .content-desc {
+            font-size: 14px;
+            line-height: 28px;
+        }
+        .media-box {
+            width: 360px;
+            height: 225px;
+            border-radius: 4px 4px 4px 4px;
+            opacity: 1;
+            border: 1px solid rgba(255, 255, 255, 0.2);
+            position: relative;
+            &.zoom-in {
+                cursor: zoom-in;
+            }
+            &.nor {
+                position: relative;
+                &::after {
+                    content: '';
+                    position: absolute;
+                    width: 100%;
+                    height: 100%;
+                    z-index: 10;
+                    opacity: 0;
+                    top: 0;
+                    left: 0;
+                }
+            }
+            // margin-bottom: 30px;
+            // &.zoom-in {
+            //     cursor: zoom-in;
+            // }
+        }
+    }
+}
+</style>

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

@@ -0,0 +1,458 @@
+<template>
+    <transition name="slide-right" mode="in-out">
+        <div class="files" v-if="showFiles && !isEdit">
+            <div class="info" ref="add$">
+                <button @click="onAdd">{{ $t('tag.addTag') }}</button>
+                <div>
+                    {{ $t('tag.isAddTag') }}(<span>{{ tags.length }}</span
+                    >)
+                </div>
+            </div>
+            <div class="list" :style="`height:${listStyle};`">
+                <ul>
+                    <li v-for="tag in tags" @click="onClick(tag)" :class="{ active: notify?.sid == tag.sid }">
+                        <div class="title"><i></i>{{ tag.title }}</div>
+                        <div class="more" @click.stop="onShowMore(tag)">
+                            <i class="iconfont icon-more"></i>
+                            <div v-if="showMoreSid == tag.id" v-click-outside="onOutside">
+                                <div @click.stop="onMoreHandler('modify', tag)">{{ $t('common.edit') }}</div>
+                                <div @click.stop="onMoreHandler('delete', tag)">{{ $t('common.delete') }}</div>
+                            </div>
+                        </div>
+                    </li>
+                </ul>
+            </div>
+            <div class="exit" ref="exit$">
+                <button type="button" @click="emits('exit')">{{ $t('common.exit') }}</button>
+            </div>
+        </div>
+    </transition>
+    <transition name="slide-up" mode="in-out">
+        <div class="toolbar" v-if="showToolbar">
+            <button type="button" @click="onAddCancel">{{ $t('common.cancel') }}</button>
+            <button type="submit" @click="onAddConfirm">{{ $t('common.confirm') }}</button>
+        </div>
+    </transition>
+    <ui-confirm v-if="delComfirm" @ok="handlerDel" @no="handlerDel">
+        <template #content>
+            <div>{{ $t('tag.deleteTagText') }}</div>
+        </template>
+    </ui-confirm>
+    <Toast v-if="showTips" type="warn" :content="showTips" :close="() => (showTips = null)" />
+</template>
+<script setup>
+import { ref, inject, watchEffect, onMounted, nextTick } from 'vue'
+import browser from '@/utils/browser'
+import { http } from '@/utils/request'
+import UiConfirm from '@/components/dialog/Confirm.vue'
+import Toast from '@/components/dialog/Toast'
+let editTag = null
+let tempTag = null
+const exit$ = ref(null)
+const add$ = ref(null)
+const props = defineProps(['show'])
+const emits = defineEmits(['add', 'exit'])
+const showTips = ref(null)
+const handlerDel = status => {
+    if (status == 'ok') {
+        http.post(`smart-site/marking/del`, {
+            markingId: delComfirm.value.id,
+        }).then(response => {
+            if (response.success) {
+                let index = tags.value.findIndex(item => item.sid == delComfirm.value.sid)
+                if (index != -1) {
+                    tags.value.splice(index, 1)
+                }
+                if (notify.value && notify.value.id == delComfirm.value.id) {
+                    notify.value = null
+                }
+            } else {
+                showTips.value = response.message
+            }
+            delComfirm.value = null
+        })
+    } else {
+        delComfirm.value = null
+    }
+}
+const delComfirm = ref(null)
+const showFiles = ref(false)
+const showToolbar = ref(false)
+const showMoreSid = ref('')
+const tags = inject('tags')
+
+const notify = inject('notify')
+const isEdit = inject('isEdit')
+const onClick = tag => {
+    notify.value = tag //{ event: 'focus', sid: props.tag.sid, tag: props.tag }
+}
+let isAdd = false
+const onAdd = () => {
+    isEdit.value = true
+    isAdd = true
+    if (window.kankan) {
+        window.kankan.TagManager.editor.then(editor => {
+            editor.enter()
+            showFiles.value = false
+            showToolbar.value = true
+        })
+    } else {
+        laserPosition()
+        showFiles.value = false
+        showToolbar.value = true
+    }
+}
+const laserPosition = () => {
+    window.laser.then(sdk => {
+        sdk.addMouseDownEvent(e => {
+            if (showToolbar.value == false) {
+                return
+            }
+            if (e.button == 2) {
+                const info3d = sdk.scene.getPointByScreen({ x: e.clientX, y: e.clientY })
+                const info2d = sdk.scene.getScreenByPoint(info3d.position)
+                if (editTag) {
+                    tempTag = {}
+                    tempTag.x = editTag.x
+                    tempTag.y = editTag.y
+                    tempTag.visible = editTag.visible
+                    tempTag.position = new THREE.Vector3(editTag.position.x, editTag.position.y, editTag.position.z)
+
+                    editTag.x = info2d.pos.x
+                    editTag.y = info2d.pos.y
+                    editTag.visible = info2d.trueSide
+                    editTag.position = info3d.position
+                } else {
+                    const tag = {
+                        panoId: null,
+                        createTime: Date.now(),
+                        icon: '',
+                        x: info2d.pos.x,
+                        y: info2d.pos.y,
+                        position: info3d.position,
+                        media: null,
+                        type: '',
+                        title: '',
+                        content: '',
+                        sid: Date.now().toString(),
+                        visible: info2d.trueSide,
+                        __temp: true,
+                    }
+                    let index = tags.value.findIndex(item => item.__temp == true)
+                    if (index != -1) {
+                        tags.value.splice(index, 1)
+                    }
+                    tags.value.push(tag)
+                }
+            }
+        })
+    })
+}
+const onAddCancel = () => {
+    showFiles.value = true
+    showToolbar.value = false
+    isAdd = false
+    if (window.kankan) {
+        kankan.TagManager.editor.then(editor => editor.exit())
+    } else {
+        let index = tags.value.findIndex(item => item.__temp == true)
+        if (index != -1) {
+            tags.value.splice(index, 1)
+        }
+    }
+
+    if (tempTag) {
+        let tag = tags.value.find(item => item.sid == editTag.sid)
+        tag.x = tempTag.x
+        tag.y = tempTag.y
+        tag.visible = tempTag.visible
+        tag.position = tempTag.position
+    }
+    editTag = null
+    tempTag = null
+    isEdit.value = false
+}
+const onAddConfirm = () => {
+    showFiles.value = true
+    showToolbar.value = false
+    if (window.kankan) {
+        kankan.TagManager.editor.then(editor => {
+            var tag = editor.confirm()
+            if (tag) {
+                tag.icon = ''
+                if (isAdd) {
+                    tag.__temp = true
+                    tag._add = true
+                    isAdd = false
+                }
+                // tags.value.push(tag)
+                notify.value = tag
+                isEdit.value = true
+            }
+        })
+    } else {
+        let tag
+        if (isAdd) {
+            tag = tags.value.find(item => item.__temp == true)
+        } else {
+            tag = editTag
+        }
+        if (tag) {
+            // delete tag.__temp
+
+            if (tag) {
+                if (isAdd) {
+                    tag.__temp = true
+                    // tag._add = true
+                    isAdd = false
+                }
+
+                notify.value = tag
+            }
+        }
+    }
+    editTag = null
+    tempTag = null
+}
+const onOutside = () => {
+    if (showMoreSid.value) {
+        showMoreSid.value = ''
+    }
+}
+const onShowMore = tag => {
+    showMoreSid.value = tag.id
+}
+const onMoreHandler = (type, tag) => {
+    if (type == 'modify') {
+        if (notify.value) {
+            notify.value = null
+        }
+        editTag = tags.value.find(item => item.sid == tag.sid)
+        showFiles.value = false
+        showToolbar.value = true
+        isEdit.value = true
+
+        if (window.kankan) {
+            window.kankan.TagManager.focusTag(editTag.sid)
+            window.kankan.TagManager.editor.then(editor => {
+                editor.enter(editTag)
+            })
+        } else {
+            laserPosition()
+        }
+        // window.kankan.TagManager.focusBeforeModify(editTag.sid)
+    } else if (type == 'delete') {
+        delComfirm.value = tag
+    }
+    showMoreSid.value = ''
+}
+const onTagFocus = tag => {
+    //tid.
+}
+const listStyle = ref('')
+watchEffect(() => {
+    showFiles.value = props.show
+    if (showFiles.value && !isEdit.value) {
+        nextTick(() => {
+            listStyle.value = `calc(100vh - ${add$.value.offsetHeight + exit$.value.offsetHeight + 60}px)`
+        })
+    }
+})
+
+onMounted(() => {})
+</script>
+<style lang="scss" scoped>
+button {
+    width: 100px;
+    height: 34px;
+    border: none;
+    outline: none;
+    border-radius: 4px;
+    font-size: 14px;
+    background: transparent;
+    transition: all 0.3s ease;
+    border: solid 1px rgb(0, 118, 246);
+    color: rgb(0, 118, 246);
+    margin: 0;
+    padding: 0;
+    &[type='submit'] {
+        background: rgb(0, 118, 246);
+        color: #fff;
+    }
+}
+.files {
+    display: flex;
+    flex-direction: column;
+    position: absolute;
+    padding: 10px 0;
+    top: 57px;
+    right: 0;
+    bottom: 0;
+    width: 240px;
+    background: rgba(27, 27, 28, 0.8);
+    z-index: 1000;
+    .info {
+        padding: 10px;
+        div {
+            font-size: 16px;
+            font-weight: bold;
+            color: #999999;
+            margin-top: 20px;
+            padding-top: 20px;
+            border-top: solid 1px rgba(255, 255, 255, 0.16);
+            span {
+                color: rgb(0, 118, 246);
+                font-weight: normal;
+            }
+        }
+        button {
+            width: 100%;
+            border: 1px solid rgba(255, 255, 255, 0.4);
+            color: rgba(255, 255, 255, 0.4);
+            &:hover {
+                border-color: #fff;
+                color: #fff;
+            }
+        }
+    }
+    .list {
+        flex: 1;
+        overflow-y: auto;
+    }
+    .exit {
+        padding: 0 10px;
+        button {
+            width: 100%;
+            border: 1px solid rgba(255, 255, 255, 0.4);
+            color: rgba(255, 255, 255, 0.4);
+            &:hover {
+                border-color: #fff;
+                color: #fff;
+            }
+        }
+    }
+
+    .add {
+        padding-bottom: 20px;
+        border-bottom: solid 1px rgba(255, 255, 255, 0.16);
+        button {
+            width: 100%;
+            border: 1px solid rgba(255, 255, 255, 0.4);
+            color: rgba(255, 255, 255, 0.4);
+            &:hover {
+                border-color: #fff;
+                color: #fff;
+            }
+        }
+    }
+}
+.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;
+        margin: 0 10px;
+    }
+}
+li {
+    cursor: pointer;
+    height: 44px;
+    padding: 10px;
+    list-style: none;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    font-size: 14px;
+    &.active {
+        background-color: rgba(0, 118, 246, 0.1);
+    }
+    .title {
+    }
+    .more {
+        position: relative;
+        cursor: pointer;
+        width: 30px;
+        height: 30px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        &.active {
+            > div {
+                display: block;
+            }
+        }
+        i {
+            font-size: 14px;
+        }
+        > div {
+            min-width: 82px;
+            background: #222121;
+            border-radius: 4px;
+            border: 1px solid #000;
+            backdrop-filter: blur(4px);
+            position: absolute;
+            right: 6px;
+            top: 26px;
+            z-index: 100;
+            div {
+                text-align: center;
+                height: 32px;
+                line-height: 32px;
+                &:hover {
+                    background-color: rgb(0, 118, 246, 0.1);
+                }
+            }
+        }
+    }
+    &:hover {
+        background-color: rgb(0, 118, 246, 0.1);
+    }
+}
+
+.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>

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

@@ -0,0 +1,72 @@
+<template>
+    <div class="control">
+        <div class="component area">
+            <textarea :maxlength="maxlength" :placeholder="placeholder" :value="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,
+        default: '',
+    },
+    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>

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

@@ -0,0 +1,171 @@
+<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" :value="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, watch } 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
+}
+
+watch(props, () => {
+    if (props.type == 'select' && props.modelValue) {
+        let find = props.data.find(item => item.value == props.modelValue)
+        if (find) {
+            selecterText.value = find.text
+        }
+    }
+})
+</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 || $t('tag.unkownUser') }}</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 || $t('tag.unkownUser') }}</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>

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

@@ -0,0 +1,133 @@
+<template>
+    <div class="media" v-if="media.length">
+        <i class="iconfont icon-music"></i>
+        <span class="music-name">{{ media[0].name }}</span>
+    </div>
+    <div class="placeholder" @click="file.click()" v-if="!media.length">
+        <div class="icon">
+            <i class="iconfont icon-add"></i>
+            <span>{{ $t('components.uploadAudio') }}</span>
+        </div>
+        <div class="tips">{{ $t('components.limitFileSize', { file: 'mp3/wav', size: '5' }) }}</div>
+        <input ref="file" type="file" style="display: none" accept=".mp3, .wav" @change="onChange" />
+    </div>
+    <div class="del-btn" v-if="isEdit && notify.media?.[notify.type]?.length" @click="delMedia">
+        <i class="iconfont icon-del"></i>
+    </div>
+</template>
+<script setup>
+import { ref, onMounted, inject } from 'vue'
+import { checkSizeLimitFree, base64ToDataURL } from '@/utils/file'
+import i18n from '@/i18n'
+const { t } = i18n.global
+const notify = inject('notify')
+const isEdit = inject('isEdit')
+const emits = defineEmits(['tips'])
+const file = ref(null)
+const media = 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 () {
+            media.value.push({ src: base64ToDataURL(reader.result), file, name: file.name })
+            notify.value.media['audio'] = media.value
+        }
+        reader.readAsDataURL(file)
+    } else {
+        emits('tips', t('components.FileSizeTips', { size: '5', file: 'mp3/wav' }))
+    }
+    e.target.value = ''
+}
+
+const delMedia = () => {
+    media.value.splice(0, 1)
+}
+onMounted(() => {
+    media.value = notify.value.media?.audio || []
+})
+</script>
+<style lang="scss" scoped>
+.del-btn {
+    width: 24px;
+    height: 24px;
+    background: rgba(0, 0, 0, 0.6);
+    border-radius: 50%;
+    position: absolute;
+    cursor: pointer;
+    top: 10px;
+    right: 10px;
+    z-index: 10;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    .iconfont {
+        font-size: 1em;
+    }
+}
+.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%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    .music-name {
+        margin-left: 5px;
+    }
+    .iconfont {
+        font-size: 1em;
+    }
+    .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>

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

@@ -0,0 +1,197 @@
+<template>
+    <div class="media-image" v-show="images.length">
+        <div class="swiper" ref="swiper$">
+            <div class="swiper-wrapper">
+                <div class="swiper-slide" v-for="(item, index) in images" :style="`background-image: url(${item.src})`" :key="index"></div>
+            </div>
+            <div class="swiper-button-prev" @click.stop=""></div>
+            <div class="swiper-button-next" @click.stop=""></div>
+        </div>
+        <div v-if="isEdit" class="add" @click="file.click()" :class="{ disable: images.length >= 9 }">
+            <span style="color: #fff" v-if="images.length < 9">{{ $t('components.continueAdd') }}</span
+            >&nbsp;<span>{{ images.length }}</span
+            >&nbsp;/&nbsp;9
+        </div>
+    </div>
+    <div v-if="isEdit" class="placeholder" @click="file.click()" v-show="images.length == 0">
+        <div class="icon">
+            <i class="iconfont icon-add"></i>
+            <span>{{ $t('components.uploadImg') }}</span>
+        </div>
+        <div class="tips">{{ $t('components.limitImgLength') }}</div>
+        <input ref="file" multiple type="file" style="display: none" accept="image/jpg,image/jpeg,image/png" @change="onChange" />
+    </div>
+
+    <div class="del-btn" v-if="isEdit" v-show="notify.media?.[notify.type]?.length" @click="delPic">
+        <i class="iconfont icon-del"></i>
+    </div>
+</template>
+<script setup>
+import { ref, onMounted, inject } from 'vue'
+import { checkSizeLimitFree, base64ToDataURL, convertBlob2File, base64ToBlob } from '@/utils/file'
+import common from '@/utils/common'
+import i18n from '@/i18n'
+const { t } = i18n.global
+const notify = inject('notify')
+const isEdit = inject('isEdit')
+const emits = defineEmits(['tips'])
+
+const file = ref(null)
+const swiper$ = ref(null)
+
+const images = ref([])
+const onChange = e => {
+    let files = e.target.files
+    if (!files.length) {
+        return
+    }
+
+    // let file = e.target.files[0]
+    let frist = false
+    for (let i = 0; i < files.length; i++) {
+        let file = e.target.files[i]
+        if (checkSizeLimitFree(file.size, 5)) {
+            let reader = new FileReader()
+            reader.onload = function () {
+                if (images.value.length >= 9) {
+                    if (!frist) {
+                        frist = true
+                        emits('tips', t('components.TipsImgLength'))
+                    }
+                } else {
+                    images.value.push({ src: base64ToDataURL(reader.result), file })
+                    notify.value.media['image'] = images.value
+                }
+            }
+            reader.readAsDataURL(file)
+        } else {
+            emits('tips', t('components.FileSizeTips', { size: '5', file: 'jpg/png' }))
+        }
+        console.log(images.value.length)
+    }
+
+    e.target.value = ''
+}
+const delPic = () => {
+    let index = swiper.activeIndex
+    images.value.splice(index, 1)
+}
+let swiper
+onMounted(() => {
+    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)
+            },
+        },
+    })
+    images.value = notify.value.media?.image || []
+
+    // if (!notify.value.media) {
+    //     notify.value.media = {}
+    // }
+
+    // notify.value.media['image'] = images.value
+    console.log(swiper)
+})
+</script>
+<style lang="scss" scoped>
+.del-btn {
+    width: 24px;
+    height: 24px;
+    background: rgba(0, 0, 0, 0.6);
+    border-radius: 50%;
+    position: absolute;
+    cursor: pointer;
+    top: 10px;
+    right: 10px;
+    z-index: 10;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    .iconfont {
+        font-size: 1em;
+    }
+}
+.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>

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

@@ -0,0 +1,140 @@
+<template>
+    <div class="media" v-show="url">
+        <iframe v-if="url" :src="url" frameborder="0"></iframe>
+        <div v-if="isEdit" class="delete" @click.stop="onDelete"><i class="iconfont icon-delete"></i></div>
+        <div class="link" v-if="isEdit">{{ url }}</div>
+    </div>
+    <div class="placeholder" v-show="url == null">
+        <div class="icon">
+            <span>{{$t('components.linkView')}}</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, inject, computed } from 'vue'
+const emits = defineEmits(['tips'])
+const notify = inject('notify')
+const isEdit = inject('isEdit')
+const url = ref(null)
+const href = ref('')
+const onDelete = () => {
+    url.value = null
+    notify.value.media['link'] = []
+}
+const onConfirm = () => {
+    if (href.value) {
+        url.value = 'https://' + href.value.replace(/http(s?):\/\//, '')
+        href.value = ''
+    }
+
+    notify.value.media['link'] = [{ src: url.value }]
+}
+
+onMounted(() => {
+    url.value = notify.value.media?.link && notify.value.media.link[0] ? notify.value.media.link[0].src : null
+})
+</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>

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

@@ -0,0 +1,135 @@
+<template>
+    <div class="media" v-if="media.length">
+        <video
+            x5-video-player-type="h5-page"
+            controlslist="nodownload"
+            disablepictureinpicture=""
+            webkit-playsinline=""
+            x-webkit-airplay=""
+            playsinline=""
+            controls=""
+            autoplay=""
+            :src="media[0].src"
+        ></video>
+    </div>
+    <div class="placeholder" @click="file.click()" v-if="!media.length">
+        <div class="icon">
+            <i class="iconfont icon-add"></i>
+            <span>{{$t('components.uploadVideo')}}</span>
+        </div>
+        <div class="tips">{{$t('components.limitFileSizeBit',{file:"mp4/mov",size:"20",bit:"2"})}}</div>
+        <input ref="file" type="file" style="display: none" accept=".mp4, .mov" @change="onChange" />
+    </div>
+    <div class="del-btn" v-if="notify.media?.[notify.type]?.length && isEdit" @click="delMedia">
+        <i class="iconfont icon-del"></i>
+    </div>
+</template>
+<script setup>
+import { ref, onMounted, inject } from 'vue'
+import { checkSizeLimitFree, base64ToDataURL } from '@/utils/file'
+const notify = inject('notify')
+const isEdit = inject('isEdit')
+const emits = defineEmits(['tips'])
+const file = ref(null)
+const media = ref([])
+const onChange = e => {
+    if (!e.target.files.length) {
+        return
+    }
+
+    let file = e.target.files[0]
+
+    if (checkSizeLimitFree(file.size, 20)) {
+        let reader = new FileReader()
+        reader.onload = function () {
+            media.value.push({ src: base64ToDataURL(reader.result), file })
+            notify.value.media['video'] = media.value
+        }
+        reader.readAsDataURL(file)
+    } else {
+      emits('tips', t('components.FileSizeTips', { size: '5', file: 'mp4/mov' }))
+    }
+    e.target.value = ''
+}
+const delMedia = () => {
+    media.value.splice(0, 1)
+}
+onMounted(() => {
+    media.value = notify.value.media?.video || []
+})
+</script>
+<style lang="scss" scoped>
+.del-btn {
+    width: 24px;
+    height: 24px;
+    background: rgba(0, 0, 0, 0.6);
+    border-radius: 50%;
+    position: absolute;
+    cursor: pointer;
+    top: 10px;
+    right: 10px;
+    z-index: 10;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    .iconfont {
+        font-size: 1em;
+    }
+}
+.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;
+        }
+    }
+}
+video {
+    width: 100%;
+    height: 100%;
+    object-fit: contain;
+}
+</style>

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

@@ -0,0 +1,111 @@
+<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, inject, onMounted } 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 notify = inject('notify')
+// const media = ref(notify.value?.type || 'image')
+const media = computed(() => {
+    return notify.value?.type || '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
+    notify.value.type = type
+}
+onMounted(() => {
+    notify.value.type = notify.value?.type || 'image'
+ if (!notify.value.media) {
+        notify.value.media = {}
+    }
+    
+})
+</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>

+ 4 - 4
src/components/header/CopyLink.vue

@@ -3,15 +3,15 @@
         <div class="login-layer">
             <div class="login-box">
                 <header>
-                    <h4>分享链接</h4>
+                    <h4>{{ $t('header.shareLink') }}</h4>
                     <span class="close" @click="emits('close')"><i class="iconfont icon-close"></i></span>
                 </header>
                 <main>
                     <input readonly v-model="shareURL" />
                 </main>
                 <footer>
-                    <button @click="emits('close')">取消</button>
-                    <button type="submit" ref="copy" :data-clipboard-text="shareURL">复制链接</button>
+                    <button @click="emits('close')">{{ $t('common.cancel') }}</button>
+                    <button type="submit" ref="copy" :data-clipboard-text="shareURL">{{$t('header.copyLink')}}</button>
                 </footer>
             </div>
         </div>
@@ -21,7 +21,7 @@
 import ClipboardJS from 'clipboard'
 import { ref, onMounted } from 'vue'
 import Toast from '@/components/dialog/Toast'
-const emits = defineEmits(['close','done'])
+const emits = defineEmits(['close', 'done'])
 const shareURL = ref(window.location.href.replace('&split', '').replace('&adjust', ''))
 const showToast = ref(false)
 const copy = ref(null)

+ 13 - 9
src/components/header/Login.vue

@@ -4,7 +4,7 @@
             <div class="login-box">
                 <span class="close" @click="emits('close')"><i class="iconfont icon-close"></i></span>
                 <div class="area">
-                    <h4>用户登录</h4>
+                    <h4>{{ $t('header.userLogin') }}</h4>
                     <div class="input">
                         <span class="icon">
                             <i class="iconfont icon-user"></i>
@@ -25,16 +25,16 @@
                     <div class="remember">
                         <div @click="remember = !remember">
                             <div class="checkbox" :class="{ checked: remember }"></div>
-                            <div class="checkbox-label">记住密码</div>
+                            <div class="checkbox-label">{{ $t('header.rememberPassword') }}</div>
                         </div>
                     </div>
                     <div class="button">
-                        <button type="submit" @click="onLogin">登录</button>
+                        <button type="submit" @click="onLogin">{{ $t('common.login') }}</button>
                         <div class="tips" v-show="errors.message">{{ errors.message }}</div>
                     </div>
                     <div class="links">
-                        <a href="http://test.4dkankan.com/#/login/forget?from=%2F">忘记密码</a>
-                        <a href="http://test.4dkankan.com/#/login/register?from=%2F">官网注册</a>
+                        <a href="http://test.4dkankan.com/#/login/forget?from=%2F">{{ $t('header.forgetPassword') }}</a>
+                        <a href="http://test.4dkankan.com/#/login/register?from=%2F">{{ $t('header.resigter') }}</a>
                     </div>
                 </div>
             </div>
@@ -45,6 +45,8 @@
 import { ref, onMounted } from 'vue'
 import { http } from '@/utils/request'
 import common from '@/utils/common'
+import { useI18n, getLocale } from '@/i18n'
+const { t } = useI18n({ useScope: 'global' })
 const emits = defineEmits(['close', 'user'])
 const showpass = ref(false)
 const remember = ref(false)
@@ -54,15 +56,15 @@ const errors = ref({})
 const onLogin = () => {
     errors.value = {}
     if (!username.value) {
-        errors.value.username = '手机号码不能为空'
+        errors.value.username = t('header.phoneText1')
         return
     }
     if (!/^1[3-9]\d{9}$/.test(username.value)) {
-        errors.value.username = '请输入正确手机号'
+        errors.value.username = t('header.phoneText2')
         return
     }
     if (!password.value) {
-        errors.value.password = '密码不能为空'
+        errors.value.username = t('header.passwordText1')
         return
     }
 
@@ -82,8 +84,10 @@ const onLogin = () => {
                     localStorage.removeItem('remember')
                     localStorage.removeItem('username')
                     localStorage.removeItem('password')
+                    localStorage.removeItem('userId')
                 }
                 localStorage.setItem('token', response.data.token)
+                localStorage.setItem('userId', response.data.user.id)
                 emits('user', {
                     head: response.data.user.head,
                     nickName: response.data.user.nickName,
@@ -94,7 +98,7 @@ const onLogin = () => {
             }
         })
         .catch(() => {
-            errors.value.message = '服务器连接失败'
+            errors.value.message = t('code.failed')
         })
 }
 onMounted(() => {

+ 54 - 30
src/components/header/index.vue

@@ -2,8 +2,8 @@
     <header v-if="props.showAdjust">
         <div v-if="project">{{ project.projectName }}</div>
         <div class="sync">
-            <button @click="onCancel">取消</button>
-            <button type="submit" @click="onSubmit" :class="{ active: points.p1 && points.p2 }">同步</button>
+            <button @click="onCancel">{{ $t('common.cancel') }}</button>
+            <button type="submit" @click="onSubmit" :class="{ active: points.p1 && points.p2 }">{{ $t('common.sync') }}</button>
         </div>
     </header>
     <header v-else>
@@ -11,41 +11,45 @@
         <div class="user">
             <ul>
                 <li>
-                    <i
-                        @click="showLink = true;showCopyDone = false" class="iconfont icon-share"
-                    ></i>
+                    <i @click="openLink" class="iconfont icon-share"></i>
                 </li>
                 <li><em></em></li>
                 <li v-if="user" class="uinfo" @click="showDrop = true">
                     <img :src="user.head + '&x-oss-process=image/resize,m_fill,w_64,h_64/quality,q_70'" alt="" />
                     <div class="menu">
                         <ul>
-                            <li><a href="/smarts/#/personal">个人信息</a></li>
+                            <li>
+                                <a href="/smarts/#/personal">{{ $t('header.userInfo') }}</a>
+                            </li>
                             <li class="split"></li>
-                            <li><a href="javascript:;" @click="onLogout">退出登录</a></li>
+                            <li>
+                                <a href="javascript:;" @click="onLogout">{{ $t('header.loginout') }}</a>
+                            </li>
                         </ul>
                     </div>
                 </li>
-                <li v-else class="login" @click="showLogin = true"><span>登录</span></li>
+                <li v-else class="login" @click="showLogin = true">
+                    <span>{{ $t('common.login') }}</span>
+                </li>
             </ul>
         </div>
     </header>
     <footer v-if="props.showAdjust">
-        <h4>为场景设置关联位置</h4>
-        <div>请选择位置,确认左右视图中的场景在同一位置后,单击右侧按钮将其设为关联位置。</div>
+        <h4>{{ $t('header.adhustText1') }}</h4>
+        <div>{{ $t('header.adhustText2') }}</div>
         <div class="points">
-            <button @click="onSetP1" :class="{ active: points.p1 }">{{ points.p1 ? '重设P1' : '设为P1' }}</button>
-            <button @click="onSetP2" :class="{ active: points.p2 }">{{ points.p2 ? '重设P2' : '设为P2' }}</button>
+            <button @click="onSetP1" :class="{ active: points.p1 }">{{ points.p1 ? `${t('header.reset')}P1` : `${t('header.setting')}P1` }}</button>
+            <button @click="onSetP2" :class="{ active: points.p2 }">{{ points.p2 ? `${t('header.reset')}P2` : `${t('header.setting')}P2` }}</button>
         </div>
     </footer>
-    <Toast v-if="showCopyDone" content="复制成功" />
+    <Toast v-if="showCopyDone" :content="t('common.copySuccess')" />
     <Toast v-if="showTips" :content="showTips" :close="() => (showTips = null)" />
-    <Login v-if="showLogin" @close="showLogin = false" @user="info => (user = info)" />
-    <Loading v-if="showLoading"/>
-    <CopyLink v-if="showLink" @close="showLink = false" @done="showCopyDone = true;showLink = false" />
+    <Login v-if="showLogin" @close="showLogin = false" @user="getUser" />
+    <Loading v-if="showLoading" />
+    <CopyLink v-if="showLink" @close="showLink = false" @done="done" />
 </template>
 <script setup>
-import { ref, defineProps, onMounted, watchEffect } from 'vue'
+import { ref, defineProps, onMounted, watchEffect, inject } from 'vue'
 import { http } from '@/utils/request'
 import browser from '@/utils/browser'
 import Toast from '@/components/dialog/Toast'
@@ -53,13 +57,14 @@ import Loading from '@/components/loading/Loading'
 import Login from './Login'
 import CopyLink from './CopyLink'
 import sync from '@/utils/sync'
-
+import { useI18n, getLocale } from '@/i18n'
+const { t } = useI18n({ useScope: 'global' })
 const props = defineProps({
     project: Object,
     showAdjust: Boolean,
 })
-const emits = defineEmits(['update'])
-
+const emits = defineEmits(['update', 'getUserId'])
+const isLogin = inject('isLogin')
 const user = ref(null)
 const points = ref({ p1: null, p2: null })
 const showLink = ref(false)
@@ -67,7 +72,19 @@ const showLoading = ref(false)
 const showLogin = ref(false)
 const showCopyDone = ref(false)
 const showTips = ref(null)
-
+const done = () => {
+    showCopyDone.value = true
+    showLink.value = false
+}
+const getUser = info => {
+    user.value = info
+    isLogin.value = true
+    // emits('getUserId', localStorage.getItem('userId'))
+}
+const openLink = () => {
+    showLink.value = true
+    showCopyDone.value = false
+}
 const getCurPosInfo = () => {
     let app = sync.sourceInst
     let id
@@ -83,22 +100,22 @@ const getCurPosInfo = () => {
 
 const onSetP1 = () => {
     let p1 = getCurPosInfo()
-    if(points.value.p2 && points.value.p2.id == p1.id){
-        return  showTips.value = '匹配失败,请选择不同点位进行同步'
+    if (points.value.p2 && points.value.p2.id == p1.id) {
+        return (showTips.value = t('header.setPointfaidText'))
     }
     if (points.value.p1) {
-        showTips.value = '关联位置已更新'
+        showTips.value = t('header.pointUpdate')
     }
     points.value.p1 = p1
     emits('update', 'p1', points.value.p1)
 }
 const onSetP2 = () => {
     let p2 = getCurPosInfo()
-    if(points.value.p1 && points.value.p1.id == p2.id){
-        return  showTips.value = '匹配失败,请选择不同点位进行同步'
+    if (points.value.p1 && points.value.p1.id == p2.id) {
+        return (showTips.value = t('header.setPointfaidText'))
     }
     if (points.value.p2) {
-        showTips.value = '关联位置已更新'
+        showTips.value = t('header.pointUpdate')
     }
     points.value.p2 = p2
     emits('update', 'p2', points.value.p2)
@@ -112,6 +129,8 @@ const getUserInfo = () => {
                     head: response.data.head,
                     nickName: response.data.nickName,
                 }
+                localStorage.setItem('userId', response.data.id)
+                isLogin.value = true
             } else {
                 if (response.code == 4008) {
                     // 未登录
@@ -130,7 +149,10 @@ const onLogout = () => [
                 localStorage.removeItem('remember')
                 localStorage.removeItem('username')
                 localStorage.removeItem('password')
+                localStorage.removeItem('userId')
                 user.value = null
+                // emits('getUserId', null)
+                isLogin.value = false
             }
         })
         .catch(() => {}),
@@ -147,7 +169,7 @@ const onSubmit = () => {
         .then(response => {
             showLoading.value = false
             if (response.success) {
-                showTips.value = 'BIM同步成功'
+                showTips.value = 'BIM' + t('common.syncSuccess')
                 setTimeout(() => {
                     window.location.href = window.location.href.replace('&adjust', '&split')
                 }, 4000)
@@ -159,7 +181,7 @@ const onSubmit = () => {
         })
         .catch(() => {
             showLoading.value = false
-            showTips.value = '连接服务器失败'
+            showTips.value = t('code.failed')
         })
 }
 
@@ -187,12 +209,14 @@ li {
 }
 header {
     position: relative;
-    height: 60px;
+    height: 61px;
     background-color: #1a1b1d;
     display: flex;
     justify-content: center;
     align-items: center;
     font-size: 16px;
+    outline: none;
+    border: none;
 }
 footer {
     position: absolute;

+ 68 - 0
src/i18n/index.js

@@ -0,0 +1,68 @@
+import { nextTick } from 'vue'
+import { useI18n, createI18n } from 'vue-i18n/index'
+import browser from '@/utils/browser'
+
+export { useI18n }
+export const SUPPORT_LOCALES = ['zh', 'en', 'ja']
+
+export function getLocale() {
+    let lang = browser.getURLParam('lang')
+    if (!lang) {
+        lang = window.navigator.language || window.navigator.userLanguage || null
+        if (lang && !/^zh/.test(lang)) {
+            console.log('自动获取浏览器语言:' + lang)
+            lang = 'en'
+        } else {
+            lang = 'zh'
+        }
+    }
+    return lang
+}
+
+export function setupI18n(options = { locale: 'zh' }) {
+    const i18n = createI18n(options)
+    setI18nLanguage(i18n, options.locale)
+    return i18n
+}
+
+export function setI18nLanguage(i18n, locale) {
+    if (i18n.mode === 'legacy') {
+        i18n.global.locale = locale
+    } else {
+        i18n.global.locale.value = locale
+    }
+    /**
+     * NOTE:
+     * If you need to specify the language setting for headers, such as the `fetch` API, set it here.
+     * The following is an example for axios.
+     *
+     * axios.defaults.headers.common['Accept-Language'] = locale
+     */
+    document.querySelector('html').setAttribute('lang', locale)
+}
+
+export async function loadLocaleMessages(i18n, locale) {
+    // load locale messages with dynamic import
+    // set locale and locale message
+    if (window.location.search.includes('i18n')) {
+        try {
+            const messages = await (await fetch(`https://4dkk.4dage.com/v4/www/locales/${locale}.json?_=${Date.now()}`)).json()
+            i18n.global.setLocaleMessage(locale, messages)
+        } catch (error) {
+            const messages = await import(/* webpackChunkName: "locale-[request]" */ `../locales/zh.json`)
+            i18n.global.setLocaleMessage(locale, messages.default)
+        }
+    } else {
+        const messages = await import(/* webpackChunkName: "locale-[request]" */ `../locales/${locale}.json`)
+        i18n.global.setLocaleMessage(locale, messages.default)
+    }
+
+    return nextTick()
+}
+
+export default setupI18n({
+    globalInjection: true,
+    legacy: false,
+    locale: '',
+    fallbackLocale: 'zh',
+})

+ 105 - 0
src/locales/en.json

@@ -0,0 +1,105 @@
+{
+    "home": {
+        "thickness": "密度选择",
+        "dateScene": "选择日期未上传场景",
+        "notChoosePoint": "您还未选择关联位置",
+        "notFindFile": "未发现BIM文件",
+        "notFindScene": "未发现对比场景",
+        "sceneDelete": "当前场景已被删除,无法进行查看",
+        "tag": "tag",
+        "splitScreen": "分屏",
+        "fullScreen": "全屏"
+    },
+    "home.name": "首页",
+    "header": {
+        "passwordText1": "密码不能为空",
+        "phoneText1": "手机号码不能为空",
+        "phoneText2": "请输入正确手机号",
+        "copyLink": "复制链接",
+        "shareLink": "分享链接",
+        "setting": "设为",
+        "reset": "重设",
+        "userInfo": "个人信息",
+        "loginout": "退出登录",
+        "adhustText1": "为场景设置关联位置",
+        "adhustText2": "请选择位置,确认左右视图中的场景在同一位置后,单击右侧按钮将其设为关联位置。",
+        "userLogin": "用户登录",
+        "rememberPassword": "记住密码",
+        "forgetPassword": "忘记密码",
+        "resigter": "官网注册",
+        "setPointfaidText": "匹配失败,请选择不同点位进行同步",
+        "pointUpdate": "关联位置已更新"
+    },
+    "components": {
+        "uploadVideo": "上传视频",
+        "limitFileSizeBit": "支持 {file} 文件:≤ {size}MB,≤ {bit}Mbps",
+        "linkView": "网页展示区",
+        "continueAdd": "继续添加",
+        "uploadImg": "上传图片",
+        "limitImgLength": "支持JPG、PNG等图片格式,单张不超过5MB,最多支持上传9张。",
+        "TipsImgLength": "图片数量最多支持上传9张",
+        "limitFileSize": "支持 {file} 文件:≤ {size}MB",
+        "FileSizeTips": "请上传 {size}MB 以内的 {file} 文件",
+        "uploadAudio": "上传音频",
+        "year": "年",
+        "month": "月",
+        "day": "日",
+        "chooseTime": "选择时间"
+    },
+    "tag": {
+        "addComment": "发一条评论吧",
+        "addCommentTips": "请输入内容",
+        "deletetCommentTips": "确定要删除吗?",
+        "noComment": "暂无评论",
+        "unkownUser": "未知用户",
+        "reply": "reply",
+        "comment": "comment",
+        "creater": "创建人",
+        "createTime": "创建时间",
+        "statusText1": "待处理",
+        "statusText2": "进行中",
+        "statusText3": "未解决",
+        "statusText4": "已解决",
+        "uploadFile": "上传附件",
+        "desc": "描述",
+        "member": "涉及的成员",
+        "status": "状态",
+        "inputDesc": "请输入描述",
+        "inputMember": "请选择需要通知的项目人员",
+        "inputStatus": "请选择处理状态",
+        "inputTagName": "请输入标注名称",
+        "tagName": "标注名称",
+        "creatTag": "新建标注",
+        "addTag": "添加标注",
+        "isAddTag": "已添加标注",
+        "deleteTagText": "确定要删除资料吗?"
+    },
+    "tag.name": "标注",
+    "components.name": "组件",
+    "header.name": "头部",
+    "common": {
+        "high": "高",
+        "middle": "中",
+        "low": "低",
+        "input": "请输入",
+        "publish": "发布",
+        "submit": "提交",
+        "exit": "退出",
+        "delete": "删除",
+        "edit": "编辑",
+        "confirm": "confirm",
+        "tips": "tips",
+        "login": "login",
+        "cancel": "cancel",
+        "sync": "sync",
+        "deleteSuccess": "删除成功",
+        "copySuccess": "复制成功",
+        "syncSuccess": "同步成功"
+    },
+    "common.name": "通用",
+    "code": {
+        "4008": "用户未登录",
+        "failed": "连接服务器失败"
+    },
+    "code.name": "状态码"
+}

+ 105 - 0
src/locales/ja.json

@@ -0,0 +1,105 @@
+{
+    "home": {
+        "thickness": "密度选择",
+        "dateScene": "选择日期未上传场景",
+        "notChoosePoint": "您还未选择关联位置",
+        "notFindFile": "未发现BIM文件",
+        "notFindScene": "未发现对比场景",
+        "sceneDelete": "当前场景已被删除,无法进行查看",
+        "tag": "标注",
+        "splitScreen": "分屏",
+        "fullScreen": "全屏"
+    },
+    "home.name": "首页",
+    "header": {
+        "passwordText1": "密码不能为空",
+        "phoneText1": "手机号码不能为空",
+        "phoneText2": "请输入正确手机号",
+        "copyLink": "复制链接",
+        "shareLink": "分享链接",
+        "setting": "设为",
+        "reset": "重设",
+        "userInfo": "个人信息",
+        "loginout": "退出登录",
+        "adhustText1": "为场景设置关联位置",
+        "adhustText2": "请选择位置,确认左右视图中的场景在同一位置后,单击右侧按钮将其设为关联位置。",
+        "userLogin": "用户登录",
+        "rememberPassword": "记住密码",
+        "forgetPassword": "忘记密码",
+        "resigter": "官网注册",
+        "setPointfaidText": "匹配失败,请选择不同点位进行同步",
+        "pointUpdate": "关联位置已更新"
+    },
+    "components": {
+        "uploadVideo": "上传视频",
+        "limitFileSizeBit": "支持 {file} 文件:≤ {size}MB,≤ {bit}Mbps",
+        "linkView": "网页展示区",
+        "continueAdd": "继续添加",
+        "uploadImg": "上传图片",
+        "limitImgLength": "支持JPG、PNG等图片格式,单张不超过5MB,最多支持上传9张。",
+        "TipsImgLength": "图片数量最多支持上传9张",
+        "limitFileSize": "支持 {file} 文件:≤ {size}MB",
+        "FileSizeTips": "请上传 {size}MB 以内的 {file} 文件",
+        "uploadAudio": "上传音频",
+        "year": "年",
+        "month": "月",
+        "day": "日",
+        "chooseTime": "选择时间"
+    },
+    "tag": {
+        "addComment": "发一条评论吧",
+        "addCommentTips": "请输入内容",
+        "deletetCommentTips": "确定要删除吗?",
+        "noComment": "暂无评论",
+        "unkownUser": "未知用户",
+        "reply": "回复",
+        "comment": "评论",
+        "creater": "创建人",
+        "createTime": "创建时间",
+        "statusText1": "待处理",
+        "statusText2": "进行中",
+        "statusText3": "未解决",
+        "statusText4": "已解决",
+        "uploadFile": "上传附件",
+        "desc": "描述",
+        "member": "涉及的成员",
+        "status": "状态",
+        "inputDesc": "请输入描述",
+        "inputMember": "请选择需要通知的项目人员",
+        "inputStatus": "请选择处理状态",
+        "inputTagName": "请输入标注名称",
+        "tagName": "标注名称",
+        "creatTag": "新建标注",
+        "addTag": "添加标注",
+        "isAddTag": "已添加标注",
+        "deleteTagText": "确定要删除资料吗?"
+    },
+    "tag.name": "标注",
+    "components.name": "组件",
+    "header.name": "头部",
+    "common": {
+        "high": "高",
+        "middle": "中",
+        "low": "低",
+        "input": "请输入",
+        "publish": "发布",
+        "submit": "提交",
+        "exit": "退出",
+        "delete": "删除",
+        "edit": "编辑",
+        "confirm": "确定",
+        "tips": "提示",
+        "login": "登录",
+        "cancel": "取消",
+        "sync": "同步",
+        "deleteSuccess": "删除成功",
+        "copySuccess": "复制成功",
+        "syncSuccess": "同步成功"
+    },
+    "common.name": "通用",
+    "code": {
+        "4008": "用户未登录",
+        "failed": "连接服务器失败"
+    },
+    "code.name": "状态码"
+}

+ 105 - 0
src/locales/zh.json

@@ -0,0 +1,105 @@
+{
+  "home": {
+    "thickness": "密度选择",
+    "dateScene": "选择日期未上传场景",
+    "notChoosePoint": "您还未选择关联位置",
+    "notFindFile": "未发现BIM文件",
+    "notFindScene": "未发现对比场景",
+    "sceneDelete": "当前场景已被删除,无法进行查看",
+    "tag": "标注",
+    "splitScreen": "分屏",
+    "fullScreen": "全屏"
+  },
+  "home.name": "首页",
+  "header": {
+    "passwordText1": "密码不能为空",
+    "phoneText1": "手机号码不能为空",
+    "phoneText2": "请输入正确手机号",
+    "copyLink": "复制链接",
+    "shareLink": "分享链接",
+    "setting": "设为",
+    "reset": "重设",
+    "userInfo": "个人信息",
+    "loginout": "退出登录",
+    "adhustText1": "为场景设置关联位置",
+    "adhustText2": "请选择位置,确认左右视图中的场景在同一位置后,单击右侧按钮将其设为关联位置。",
+    "userLogin": "用户登录",
+    "rememberPassword": "记住密码",
+    "forgetPassword": "忘记密码",
+    "resigter": "官网注册",
+    "setPointfaidText": "匹配失败,请选择不同点位进行同步",
+    "pointUpdate": "关联位置已更新"
+  },
+  "components": {
+    "uploadVideo": "上传视频",
+    "limitFileSizeBit": "支持 {file} 文件:≤ {size}MB,≤ {bit}Mbps",
+    "linkView": "网页展示区",
+    "continueAdd": "继续添加",
+    "uploadImg": "上传图片",
+    "limitImgLength": "支持JPG、PNG等图片格式,单张不超过5MB,最多支持上传9张。",
+    "TipsImgLength": "图片数量最多支持上传9张",
+    "limitFileSize": "支持 {file} 文件:≤ {size}MB",
+    "FileSizeTips": "请上传 {size}MB 以内的 {file} 文件",
+    "uploadAudio": "上传音频",
+    "year": "年",
+    "month": "月",
+    "day": "日",
+    "chooseTime": "选择时间"
+  },
+  "tag": {
+    "addComment": "发一条评论吧",
+    "addCommentTips": "请输入内容",
+    "deletetCommentTips": "确定要删除吗?",
+    "noComment": "暂无评论",
+    "unkownUser": "未知用户",
+    "reply": "回复",
+    "comment": "评论",
+    "creater": "创建人",
+    "createTime": "创建时间",
+    "statusText1": "待处理",
+    "statusText2": "进行中",
+    "statusText3": "未解决",
+    "statusText4": "已解决",
+    "uploadFile": "上传附件",
+    "desc": "描述",
+    "member": "涉及的成员",
+    "status": "状态",
+    "inputDesc": "请输入描述",
+    "inputMember": "请选择需要通知的项目人员",
+    "inputStatus": "请选择处理状态",
+    "inputTagName": "请输入标注名称",
+    "tagName": "标注名称",
+    "creatTag": "新建标注",
+    "addTag": "添加标注",
+    "isAddTag": "已添加标注",
+    "deleteTagText": "确定要删除资料吗?"
+  },
+  "tag.name": "标注",
+  "components.name": "组件",
+  "header.name": "头部",
+  "common": {
+    "high": "高",
+    "middle": "中",
+    "low": "低",
+    "input": "请输入",
+    "publish": "发布",
+    "submit": "提交",
+    "exit": "退出",
+    "delete": "删除",
+    "edit": "编辑",
+    "confirm": "确定",
+    "tips": "提示",
+    "login": "登录",
+    "cancel": "取消",
+    "sync": "同步",
+    "deleteSuccess": "删除成功",
+    "copySuccess": "复制成功",
+    "syncSuccess": "同步成功"
+  },
+  "common.name": "通用",
+  "code": {
+    "4008": "用户未登录",
+    "failed": "连接服务器失败"
+  },
+  "code.name": "状态码"
+}

+ 12 - 10
src/pages/SViewer.vue

@@ -19,9 +19,9 @@
             </div>
             <div class="item-density" v-if="source.type == 2 && mode == 1" @click="showDensity = true">
                 <span>{{ densityType.text }}</span>
-                <div @click.stop="(showDensity = false)" v-if="showDensity">
+                <div @click.stop="showDensity = false" v-if="showDensity">
                     <ul>
-                        <li class="title">密度选择</li>
+                        <li class="title">{{ $t('home.thickness') }}</li>
                         <li v-for="density in densityTypes" @click="onDensityChange(density)">
                             {{ density.text }}<i class="iconfont" :class="{ 'icon-check': density.type == densityType.type }"></i>
                         </li>
@@ -39,7 +39,9 @@ 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'
+import i18n from '@/i18n'
+const { t } = i18n.global
 const isDev = process.env.VUE_APP_TEST == 1
 // 点位信息
 let lastFakeApp = null
@@ -58,9 +60,9 @@ const target = ref(null)
 const project = ref(null)
 
 const densityTypes = ref([
-    { type: 'high', text: '高' },
-    { type: 'middle', text: '中' },
-    { type: 'low', text: '低' },
+    { type: 'high', text: t('common.high') },
+    { type: 'middle', text: t('common.middle') },
+    { type: 'low', text: t('common.low') },
 ])
 
 const densityType = ref(densityTypes.value[0])
@@ -229,7 +231,7 @@ const onBimChecked = () => {
     }, 2000)
 
     if (!project.value || !project.value.bimData) {
-        showTips.value = '未发现BIM文件'
+        showTips.value = t('home.notFindFile')
         return
     }
     if (bimChecked.value) {
@@ -257,10 +259,10 @@ onMounted(() => {
                         source.value = project.value.sceneList[project.value.sceneList.length - 1]
                     }
                     if (!source.value) {
-                        return (showTips.value = '当前场景已被删除,无法进行查看')
+                        return (showTips.value = t('home.sceneDelete'))
                     }
                 } else {
-                    return (showTips.value = '当前场景已被删除,无法进行查看')
+                    return (showTips.value = t('home.sceneDelete'))
                 }
                 if (response.data.panos) {
                     response.data.panos = JSON.parse(response.data.panos)
@@ -271,7 +273,7 @@ onMounted(() => {
             }
         })
         .catch(err => {
-            showTips.value = '服务器连接失败'
+            showTips.value = t('code.failed')
         })
 })
 </script>

+ 98 - 32
src/pages/Viewer.vue

@@ -1,10 +1,10 @@
 <template>
-    <AppHeader v-show="!fscChecked" :project="project" :show-adjust="showAdjust" @update="onPointsUpdate" />
+    <AppHeader v-show="!fscChecked" :project="project" @getUserId="getUserId" :show-adjust="showAdjust" @update="onPointsUpdate" />
     <article>
         <main>
             <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="tools" v-if="source" v-show="showWidget && !showAdjust && !fscChecked && (dbsChecked || (!target && !bimChecked))">
                     <div class="item-date">
                         <calendar
                             name="source"
@@ -22,11 +22,11 @@
                         <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>
                     </div>
-                    <div class="item-density" v-if="(source.type == 2 && mode == 1)" :class="{active:showDensity}" @click="showDensity=!showDensity">
-                        <span>{{densityType.text}}</span>
+                    <div class="item-density" v-if="source.type == 2 && mode == 1" :class="{ active: showDensity }" @click="showDensity = !showDensity">
+                        <span>{{ densityType.text }}</span>
                         <i class="iconfont icon-arrows_down"></i>
                         <ul>
-                            <li v-for="density in densityTypes" @click="onDensityChange(density)">{{density.text}}</li>
+                            <li v-for="density in densityTypes" @click="onDensityChange(density)">{{ density.text }}</li>
                         </ul>
                     </div>
                 </div>
@@ -69,7 +69,13 @@
                     </div>
                 </div>
             </div>
-            <div class="model" v-show="!showAdjust">
+            <div class="model" v-show="showWidget && !showAdjust">
+                <div v-if="isLogin" class="file" :class="{ active: fileChecked, disable: fileDisable }" v-show="!fscChecked && !showBim && !dbsChecked && !bimChecked">
+                    <div @click="onFileChecked">
+                        <i class="iconfont icon-note1"></i>
+                        <span>{{ $t('home.tag') }}</span>
+                    </div>
+                </div>
                 <div class="bim" :class="{ active: bimChecked, disable: bimDisable }" v-show="!fscChecked && !showBim">
                     <div @click="onBimChecked">
                         <i class="iconfont icon-BIM"></i>
@@ -79,28 +85,50 @@
                 <div class="dbs" :class="{ active: dbsChecked, disable: dbsDisable }" v-show="!fscChecked && !showBim">
                     <div @click="onDbsChecked">
                         <i class="iconfont icon-split_screen"></i>
-                        <span>分屏</span>
+                        <span>{{ $t('home.splitScreen') }}</span>
                     </div>
                 </div>
                 <div class="fsc" :class="{ active: fscChecked }" @click="onFscChecked">
                     <i class="iconfont" :class="[fscChecked ? 'icon-full_screen_selected' : 'icon-full_screen_normal']"></i>
-                    <span>全屏</span>
+                    <span>{{ $t('home.fullScreen') }}</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" @exit="() => (fileChecked = false)" />
 </template>
 
 <script setup>
-import { ref, onMounted, computed, nextTick } from 'vue'
+import { ref, onMounted, computed, nextTick, provide, watch } 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'
+import i18n from '@/i18n'
+const { t } = i18n.global
 const isDev = process.env.VUE_APP_TEST == 1
+
+const tags = ref([])
+const notify = ref(null)
+const isEdit = ref(false)
+const isLogin = ref(false)
+provide('tags', tags)
+provide('notify', notify)
+provide('isEdit', isEdit)
+provide('isLogin', isLogin)
+const userId = ref(localStorage.getItem('userId') || null)
+
+const getUserId = id => {
+    userId.value = id
+}
+
 // 是否BIM模式
 const showBim = ref(browser.urlHasValue('bim'))
 // 是否校准模式
@@ -110,6 +138,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)
 
@@ -117,15 +146,16 @@ const sourceFrame = ref(null)
 const targetFrame = ref(null)
 
 const mode = ref(0)
+
 const source = ref(null)
 const target = ref(null)
 const project = ref(null)
 const points = ref({ p1: null, p2: null })
 
 const densityTypes = ref([
-    {type:'high',text:'高'},
-    {type:'middle',text:'中'},
-    {type:'low',text:'低'}
+    { type: 'high', text: t('common.high') },
+    { type: 'middle', text: t('common.middle') },
+    { type: 'low', text: t('common.low') },
 ])
 
 const densityType = ref(densityTypes.value[0])
@@ -134,6 +164,13 @@ const showTips = ref(null)
 
 const showDensity = ref(false)
 
+const showWidget = computed(() => {
+    if (fileChecked.value) {
+        return false
+    }
+    return true
+})
+
 const scenes = computed(() => {
     if (!project.value) {
         return []
@@ -186,7 +223,6 @@ const targetURL = computed(() => {
 })
 const sourceDate = computed(() => {
     if (source.value) {
-        console.log(source.value.createTime.toDate())
         return source.value.createTime.toDate()
     }
 })
@@ -229,6 +265,10 @@ const targetDays = computed(() => {
     }
 })
 
+const fileDisable = computed(() => {
+    return false
+})
+
 const bimDisable = computed(() => {
     if (!project.value || !project.value.bimData) {
         return true
@@ -251,7 +291,12 @@ const onLoadSource = () => {
         // BIM单屏模式
         return
     }
-
+    if (source.value.type < 2) {
+        window['kankan'] = sourceFrame.value.contentWindow.app
+        window['kankan'].TagManager.load(tags.value)
+    } else {
+        window['laser'] = sourceFrame.value.contentWindow.loaded
+    }
     loadSourceScene(sourceFrame, source.value.type < 2 ? 'kankan' : 'laser', mode.value)
 }
 const onLoadTarget = () => {
@@ -271,7 +316,7 @@ const onModeChange = targetMode => {
     }
 }
 
-const onDensityChange = (density)=>{
+const onDensityChange = density => {
     if (sync.sourceInst) {
         sync.sourceInst.loaded.then(sdk => {
             let data = sdk.scene.changePointDensity(density.type)
@@ -308,7 +353,7 @@ const onSelected = data => {
             }
         }
     } else {
-        showTips.value = '选择日期未上传场景'
+        showTips.value = t('home.dateScene')
     }
 
     datepickName.value = null
@@ -382,12 +427,14 @@ const onNextDate = name => {
     scene.value = scenes.value[index]
 }
 
-
+const onFileChecked = () => {
+    fileChecked.value = !fileChecked.value
+}
 
 // bim点击
 const onBimChecked = () => {
     if (bimDisable.value) {
-        return (showTips.value = '未发现BIM文件')
+        return (showTips.value = t('home.notFindFile'))
     }
     if (bimChecked.value) {
         bimChecked.value = false
@@ -416,7 +463,7 @@ const onBimChecked = () => {
 // 分屏点击
 const onDbsChecked = () => {
     if (dbsDisable.value && !dbsChecked.value) {
-        return (showTips.value = '未发现对比场景')
+        return (showTips.value = t('home.notFindScene'))
     }
     dbsChecked.value = !dbsChecked.value
     if (dbsChecked.value) {
@@ -475,7 +522,7 @@ const onPointsUpdate = (type, data) => {
 
 const onP1Click = type => {
     if (!points.value.p1) {
-        showTips.value = '您还未选择关联位置'
+        showTips.value = t('home.notChoosePoint')
         return
     }
     console.log(points.value.p1)
@@ -486,7 +533,7 @@ const onP1Click = type => {
 
 const onP2Click = type => {
     if (!points.value.p2) {
-        showTips.value = '您还未选择关联位置'
+        showTips.value = t('home.notChoosePoint')
         return
     }
     // todo 定位
@@ -522,14 +569,14 @@ onMounted(() => {
                             source.value = project.value.sceneList[project.value.sceneList.length - 1]
                         }
                         if (!source.value) {
-                            return (showTips.value = '当前场景已被删除,无法进行查看')
+                            return (showTips.value = t('home.sceneDelete'))
                         }
                         if (showAdjust.value || showSplit.value) {
                             onBimChecked()
                             nextTick(() => onDbsChecked())
                         }
                     } else {
-                        return (showTips.value = '当前场景已被删除,无法进行查看')
+                        return (showTips.value = t('home.sceneDelete'))
                     }
                 }
             } else {
@@ -542,8 +589,25 @@ onMounted(() => {
             }
         })
         .catch(() => {
-            showTips.value = '服务器连接失败'
+            showTips.value = t('code.failed')
         })
+    console.error(browser.getURLParam('m'))
+    http.post(`smart-site/marking/list`, {
+        projectId: projectId,
+        pageNum: 1,
+        pageSize: 200,
+        num: browser.getURLParam('m'),
+    }).then(response => {
+        if (response.data && response.data.list) {
+            tags.value = response.data.list.map(item => {
+                item.hotData.visible = false
+                item.hotData.id = item.markingId
+                item.hotData.createTime = item.createTime
+                item.hotData.lastCreateBy = item.lastCreateBy
+                return item.hotData
+            })
+        }
+    })
 })
 </script>
 
@@ -595,6 +659,8 @@ main {
         z-index: 1000;
         width: 100%;
         height: 100%;
+        border: none;
+        outline: none;
     }
     .split {
         margin-left: 2px;
@@ -738,12 +804,12 @@ main {
             margin-right: 10px;
             font-size: 16px;
             padding: 0 16px;
-            &.active{
-                ul{
+            &.active {
+                ul {
                     display: block;
                 }
-                i{
-                    transform: scale(0.8) rotate(180deg)
+                i {
+                    transform: scale(0.8) rotate(180deg);
                 }
             }
             span {
@@ -766,9 +832,9 @@ main {
                 box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
                 border: 1px solid #000000;
                 list-style: none;
-                li{
+                li {
                     padding: 5px 0;
-                    &:hover{
+                    &:hover {
                         color: #0076f6;
                     }
                 }

+ 6 - 1
src/pages/kankan.js

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

+ 12 - 2
src/pages/sviewer.js

@@ -5,7 +5,8 @@ import { createApp } from 'vue'
 import { DatetimePicker } from 'vant';
 import { setup } from '../utils/request'
 import App from './SViewer.vue'
-
+import i18n, { getLocale, setI18nLanguage, loadLocaleMessages } from '../i18n'
+const local = getLocale()
 Date.prototype.format = function(fmt = 'YYYY-mm-dd HH:MM:SS') {
     var res = ''
     try {
@@ -39,4 +40,13 @@ String.prototype.toDate = function() {
 }
 
 setup()
-createApp(App).use(DatetimePicker).mount('#app')
+// createApp(App).use(DatetimePicker).mount('#app')
+
+
+const app = createApp(App)
+loadLocaleMessages(i18n, local).then(() => {
+  setI18nLanguage(i18n, local)
+  app.use(i18n)
+  app.use(DatetimePicker)
+  app.mount('#app')
+})

+ 19 - 1
src/pages/viewer.js

@@ -2,7 +2,13 @@ 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'
+import i18n, { getLocale, setI18nLanguage, loadLocaleMessages } from '../i18n'
+const local = getLocale()
+
+
+
 
 Date.prototype.format = function(fmt = 'YYYY-mm-dd HH:MM:SS') {
     var res = ''
@@ -36,6 +42,18 @@ String.prototype.toDate = function() {
     return new Date(this.replace(/\//g, '-').replace(' ', 'T'))
 }
 
+document.oncontextmenu = function (event){
+    return false
+}
+
 setup()
 const app = createApp(App)
-app.mount('#app')
+
+
+
+loadLocaleMessages(i18n, local).then(() => {
+  setI18nLanguage(i18n, local)
+  app.use(i18n)
+  app.directive('click-outside', ClickOutSide)
+  app.mount('#app')
+})

+ 18 - 0
src/utils/ClickOutSide.js

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

+ 9 - 2
src/utils/ConvertViews.js

@@ -30,11 +30,11 @@ export default class ConvertViews extends THREE.EventDispatcher{
             
         } 
         
-        this.syncPosRot(sourceFakeApp.viewInfo, targetApp , convertInfo) //修改好位置朝向
+        //this.syncPosRot(sourceFakeApp.viewInfo, targetApp , convertInfo) //修改好位置朝向 这个4dkk的会报错但上一版是用这句
         if(sourceApp.sceneType == 'kankan' || sourceFakeApp.viewInfo.isAtPano){
             this.flyToPano(targetApp, sourceFakeApp.viewInfo.currentPano,{duration:0, zoomLevel:sourceFakeApp.viewInfo.zoomLevel})
         }
-        //this.syncView(sourceApp, targetApp, convertInfo)
+         this.syncView(sourceApp, targetApp, convertInfo)//这个不记得有什么bug了
         
         
         
@@ -69,6 +69,7 @@ export default class ConvertViews extends THREE.EventDispatcher{
         targetApp.sceneName = 'targetApp'
         
         
+        
         //后续的同步 
         
         if(sourceApp.sceneType == 'laser'){  
@@ -84,6 +85,10 @@ export default class ConvertViews extends THREE.EventDispatcher{
                 this.removeEventListener('clearBind-sameType',dispose)
             } 
             this.addEventListener('clearBind-sameType',dispose) 
+        }else if(sourceApp.sceneType == 'kankan'){
+            //暂时关闭快速过渡,因为跟不上
+            sourceApp.app.core.get('Player').setPanoTaskEnable(false)
+            targetApp.app.core.get('Player').setPanoTaskEnable(false)
         }
         
         let bind = (master, customer)=>{ //相互都能带动对方
@@ -576,6 +581,8 @@ export default class ConvertViews extends THREE.EventDispatcher{
 
         if(customer && customer.sceneType == 'laser'){
             this.laserSyncView(customer, {position,target})
+        }else if(customer && customer.sceneType == 'kankan'){
+            this.syncView(sourceApp, targetApp, convertInfo)
         }else{
             let msg = {
                 position,

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

+ 14 - 0
src/utils/browser.js

@@ -11,6 +11,20 @@ var browser = {
             }
         )
     },
+    getURLParam: function (key) {
+      let querys = window.location.search.substring(1).split('&')
+      for (let i = 0; i < querys.length; i++) {
+          let keypair = querys[i].split('=')
+          if (keypair.length === 2 && keypair[0] === key) {
+              try {
+                  return decodeURIComponent(keypair[1])
+              } catch (error) {
+                  return keypair[1]
+              }
+          }
+      }
+      return ''
+  },
     isFullscreen() {
         return document.fullscreenElement || document.mozFullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement
     },

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

+ 5 - 1
src/utils/request.js

@@ -183,6 +183,7 @@ export function setup(options = {}) {
     )
     fetch.interceptors.response.use(
         response => {
+        
             // 正常的文件流
             if (!/json/gi.test(response.headers['content-type'])) {
                 return response.data
@@ -194,7 +195,10 @@ export function setup(options = {}) {
                 let res = JSON.parse(enc.decode(new Uint8Array(response.data)))
                 return res
             }
-
+            if(response.code==4008){
+              //用户未登录
+              
+            }
             return response.data
         },
         error => {

+ 1 - 1
vue.config.js

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