浏览代码

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

xzw 1 年之前
父节点
当前提交
f2aef0f25b
共有 49 个文件被更改,包括 3297 次插入1402 次删除
  1. 13 0
      lang.html
  2. 7 1
      package.json
  3. 1084 392
      pnpm-lock.yaml
  4. 71 0
      scripts/lang.js
  5. 26 27
      src/api/constant.ts
  6. 83 78
      src/api/model.ts
  7. 35 35
      src/components/bill-ui/components/cropper/cropper.vue
  8. 3 2
      src/components/bill-ui/components/cropper/index.js
  9. 13 12
      src/components/bill-ui/components/dialog/Alert.vue
  10. 14 13
      src/components/bill-ui/components/dialog/Confirm.vue
  11. 195 165
      src/components/bill-ui/components/input/file.vue
  12. 8 6
      src/components/bill-ui/components/input/state.js
  13. 14 14
      src/components/error/index.vue
  14. 11 12
      src/components/list/index.vue
  15. 5 5
      src/components/tagging/sign.vue
  16. 0 1
      src/env/index.ts
  17. 16 0
      src/lang/constant.ts
  18. 10 0
      src/lang/en/index.ts
  19. 87 0
      src/lang/en/sys.ts
  20. 41 0
      src/lang/helper.ts
  21. 81 0
      src/lang/index.ts
  22. 10 0
      src/lang/ja/index.ts
  23. 87 0
      src/lang/ja/sys.ts
  24. 253 0
      src/lang/lang-editer/app.vue
  25. 52 0
      src/lang/lang-editer/main.ts
  26. 10 0
      src/lang/zh/index.ts
  27. 87 0
      src/lang/zh/sys.ts
  28. 8 16
      src/layout/header/index.vue
  29. 38 43
      src/layout/model-list/index.vue
  30. 28 20
      src/layout/model-list/sign.vue
  31. 2 0
      src/main.ts
  32. 5 3
      src/router/constant.ts
  33. 3 2
      src/store/guide.ts
  34. 15 7
      src/store/sys.ts
  35. 5 1
      src/store/tagging.ts
  36. 182 0
      src/utils/file-serve.ts
  37. 2 0
      src/utils/index.ts
  38. 9 1
      src/utils/params.ts
  39. 89 0
      src/utils/store.ts
  40. 113 114
      src/views/guide/edit-paths.vue
  41. 25 26
      src/views/guide/index.vue
  42. 32 34
      src/views/guide/sign.vue
  43. 76 54
      src/views/merge/index.vue
  44. 128 124
      src/views/tagging/edit.vue
  45. 117 115
      src/views/tagging/index.vue
  46. 37 45
      src/views/tagging/sign.vue
  47. 12 1
      src/vite-env.d.ts
  48. 1 1
      tsconfig.json
  49. 54 32
      vite.config.ts

+ 13 - 0
lang.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="//4dkk.4dage.com/FDKKIMG/icon/kankan_icon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>四维工地管家</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/lang/lang-editer/main.ts"></script>
+  </body>
+</html>

+ 7 - 1
package.json

@@ -5,15 +5,21 @@
   "type": "module",
   "scripts": {
     "dev": "vite",
+    "lang": "vite ./ lang",
     "build": "vue-tsc --noEmit && vite build",
     "preview": "vite preview"
   },
   "dependencies": {
+    "ant-design-vue": "^4.2.3",
     "axios": "^0.27.2",
+    "body-parser": "^1.20.2",
+    "express": "^4.19.2",
     "mitt": "^3.0.0",
     "vue": "^3.2.37",
     "vue-cropper": "^0.5.8",
-    "vue-router": "^4.1.3"
+    "vue-i18n": "^9.13.1",
+    "vue-router": "^4.1.3",
+    "xlsx": "^0.18.5"
   },
   "devDependencies": {
     "@types/node": "^18.6.5",

文件差异内容过多而无法显示
+ 1084 - 392
pnpm-lock.yaml


+ 71 - 0
scripts/lang.js

@@ -0,0 +1,71 @@
+import express from 'express'
+import path from 'path'
+import bodyParser from 'body-parser'
+import * as fs from 'fs'
+import { fileURLToPath } from 'url'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+const startup = false
+
+const enter = `
+import { modulesParse } from '../helper'
+
+const { ctx, promise } = modulesParse(
+  import.meta.glob(['./*.ts', '!./index.ts'])
+)
+
+export default ctx
+export { promise }
+
+`
+const writeFile = (path, content) =>
+  new Promise((resolve, reject) => {
+    fs.writeFile(path, content, err => {
+      if (err) {
+        reject(err)
+      } else {
+        resolve()
+      }
+    })
+  })
+
+const langDir = path.join(__dirname, '../src/lang')
+const writeLang = (name, lang) => {
+  const dir = path.join(langDir, name)
+  if (!fs.existsSync(dir)) {
+    fs.mkdirSync(dir)
+  }
+
+  return Promise.all([
+    writeFile(path.join(dir, 'index.ts'), enter),
+    ...Object.entries(lang).map(([name, content]) =>
+      writeFile(
+        path.join(dir, `${name}.ts`),
+        `export default ${JSON.stringify(content, null, 4)}`
+      )
+    )
+  ])
+}
+
+export async function createServer(port) {
+  if (startup) {
+    return
+  }
+
+  const app = express()
+  app.use('/dev', bodyParser.json())
+  app.use('/dev', bodyParser.urlencoded({ extended: false }))
+
+  app.post('/dev/langs', async function (req, res) {
+    const langs = req.body
+    const handlers = Object.entries(langs).map(([name, lang]) =>
+      writeLang(name, lang)
+    )
+    Promise.all(handlers)
+      .then(() => res.json({ ok: true }))
+      .catch(() => res.json({ ok: false }))
+  })
+  app.listen(port)
+  console.log('语言编辑已开启')
+}

+ 26 - 27
src/api/constant.ts

@@ -1,47 +1,46 @@
-import { params } from '@/env'
+import { params } from "@/env";
+import { ui18n } from "@/lang";
 
 export enum ResCode {
   TOKEN_INVALID = 4008,
-  SUCCESS = 200
+  SUCCESS = 200,
 }
 
 export const ResCodeDesc: { [key in ResCode]: string } = {
-  [ResCode.TOKEN_INVALID]: 'token已失效',
-  [ResCode.SUCCESS]: '请求成功'
-}
+  [ResCode.TOKEN_INVALID]: ui18n.t("sys.TOKEN_INVALID"),
+  [ResCode.SUCCESS]: ui18n.t("sys.SUCCESS"),
+};
 
 export const UPLOAD_HEADS = {
-  'Content-Type': 'multipart/form-data'
-}
-
+  "Content-Type": "multipart/form-data",
+};
 
 // 模型列表
-export const MODEL_LIST = `/laser/sceneFusion/${params.m}/list/${params.id}`
-export const INSERT_MODEL = `/laser/sceneFusion/${params.m}/uploadFile/${params.id}`
-export const UPDATE_MODEL = `/laser/sceneFusion/${params.m}/updateModel`
-export const DELETE_MODEL = `/laser/sceneFusion/${params.m}/del`
+export const MODEL_LIST = `/laser/sceneFusion/${params.m}/list/${params.id}`;
+export const INSERT_MODEL = `/laser/sceneFusion/${params.m}/uploadFile/${params.id}`;
+export const UPDATE_MODEL = `/laser/sceneFusion/${params.m}/updateModel`;
+export const DELETE_MODEL = `/laser/sceneFusion/${params.m}/del`;
 
 // 标注列表
-export const TAGGING_LIST = `/laser/sceneTag/${params.m}/list/${params.fushId}`
-export const INSERT_TAGGING = `/laser/sceneTag/${params.m}/add`
-export const UPDATE_TAGGING = `/laser/sceneTag/${params.m}/edit`
-export const DELETE_TAGGING = `/laser/sceneTag/${params.m}/delete`
+export const TAGGING_LIST = `/laser/sceneTag/${params.m}/list/${params.fushId}`;
+export const INSERT_TAGGING = `/laser/sceneTag/${params.m}/add`;
+export const UPDATE_TAGGING = `/laser/sceneTag/${params.m}/edit`;
+export const DELETE_TAGGING = `/laser/sceneTag/${params.m}/delete`;
 
 // 标注放置列表
-export const TAGGING_POINT_LIST = `/laser/caseTagPoint/${params.m}/list/`
-export const INSERT_TAGGING_POINT = `/laser/caseTagPoint/${params.m}/place`
-export const UPDATE_TAGGING_POINT = `/laser/sceneTag/${params.m}/edit`
-export const DELETE_TAGGING_POINT = `/laser/caseTagPoint/${params.m}/delete`
+export const TAGGING_POINT_LIST = `/laser/caseTagPoint/${params.m}/list/`;
+export const INSERT_TAGGING_POINT = `/laser/caseTagPoint/${params.m}/place`;
+export const UPDATE_TAGGING_POINT = `/laser/sceneTag/${params.m}/edit`;
+export const DELETE_TAGGING_POINT = `/laser/caseTagPoint/${params.m}/delete`;
 
 // 标注样式类型列表
-export const TAGGING_STYLE_LIST = ''
+export const TAGGING_STYLE_LIST = "";
 
 // 导览
-export const GUIDE_LIST = `/laser/fusionGuide/${params.m}/list/${params.fushId}`
-export const INSERT_GUIDE = `/laser/fusionGuide/${params.m}/add`
-export const UPDATE_GUIDE = `/laser/fusionGuide/${params.m}/edit`
-export const DELETE_GUIDE = `/laser/fusionGuide/${params.m}/delete`
-
+export const GUIDE_LIST = `/laser/fusionGuide/${params.m}/list/${params.fushId}`;
+export const INSERT_GUIDE = `/laser/fusionGuide/${params.m}/add`;
+export const UPDATE_GUIDE = `/laser/fusionGuide/${params.m}/edit`;
+export const DELETE_GUIDE = `/laser/fusionGuide/${params.m}/delete`;
 
 // 文件上传 不鉴权文件上传
-export const UPLOAD_FILE = `/laser/oss/${params.m}/fuse-code/upload/fire`
+export const UPLOAD_FILE = `/laser/oss/${params.m}/fuse-code/upload/fire`;

+ 83 - 78
src/api/model.ts

@@ -1,66 +1,67 @@
-import axios from './instance'
-import { 
+import axios from "./instance";
+import {
   MODEL_LIST,
   INSERT_MODEL,
   UPDATE_MODEL,
   DELETE_MODEL,
-  UPLOAD_HEADS
-} from './constant'
+  UPLOAD_HEADS,
+} from "./constant";
+import { ui18n } from "@/lang";
 
 export enum ModelType {
-  SWKK = 'swkk',
-  SWKJ = 'swkj',
-  SWMX = 'glb',
-  SWSS = 'laser',
-  OSGB = 'obsg',
-  B3DM = 'b3dm'
+  SWKK = "swkk",
+  SWKJ = "swkj",
+  SWMX = "glb",
+  SWSS = "laser",
+  OSGB = "obsg",
+  B3DM = "b3dm",
 }
 
-export const ModelTypeDesc: Record<ModelType, string>  = {
-  [ModelType.SWKK]: '四维看看',
-  [ModelType.SWKJ]: '四维看见',
-  [ModelType.SWSS]: '四维深时',
-  [ModelType.SWMX]: '三维模型',
-  [ModelType.OSGB]: 'osgb模型',
-  [ModelType.B3DM]: 'b3dm模型'
-}
+export const ModelTypeDesc: Record<ModelType, string> = {
+  [ModelType.SWKK]: ui18n.t("sys.SWKK"),
+  [ModelType.SWKJ]: ui18n.t("sys.SWKJ"),
+  [ModelType.SWSS]: ui18n.t("sys.SWSS"),
+  [ModelType.SWMX]: ui18n.t("sys.SWMX"),
+  [ModelType.OSGB]: ui18n.t("sys.OSGB"),
+  [ModelType.B3DM]: ui18n.t("sys.B3DM"),
+};
 
 export interface ModelAttrs {
-  show: boolean,
-  scale: number,
-  opacity: number,
-  bottom: number,
-  position: SceneLocalPos,
-  rotation: SceneLocalPos
+  show: boolean;
+  scale: number;
+  opacity: number;
+  bottom: number;
+  position: SceneLocalPos;
+  rotation: SceneLocalPos;
 }
 export interface Model extends ModelAttrs {
-  id: string
-  url: Array<string>
-  title: string
-  fusionId?: number,
-  type: ModelType
-  isSelf: boolean
-  size: number,
-  time: string
+  id: string;
+  url: Array<string>;
+  title: string;
+  fusionId?: number;
+  type: ModelType;
+  isSelf: boolean;
+  size: number;
+  time: string;
 }
 
 interface ServiceModel {
-  createTime: string,
-  id: number,
-  hide: number,
-  modelDateType: string
-  modelUrl: string
-  fusionId?: number,
-  modelSize: number
-  modelTitle: string
-  opacity: number
-  bottom: number
-  type: number
+  createTime: string;
+  id: number;
+  hide: number;
+  modelDateType: string;
+  modelUrl: string;
+  fusionId?: number;
+  modelSize: number;
+  modelTitle: string;
+  opacity: number;
+  bottom: number;
+  type: number;
   transform: {
-    position: SceneLocalPos, 
-    rotation: SceneLocalPos, 
-    scale: [number, number, number]
-  }
+    position: SceneLocalPos;
+    rotation: SceneLocalPos;
+    scale: [number, number, number];
+  };
 }
 
 const serviceToLocal = (serviceModel: ServiceModel): Model => ({
@@ -72,13 +73,20 @@ const serviceToLocal = (serviceModel: ServiceModel): Model => ({
   position: serviceModel.transform.position,
   rotation: serviceModel.transform.rotation,
   id: serviceModel.id.toString(),
-  url: serviceModel.modelUrl.split(','),
+  url: serviceModel.modelUrl.split(","),
   title: serviceModel.modelTitle,
   fusionId: serviceModel.fusionId,
-  type:  serviceModel.modelDateType === 'osgb' ? ModelType.OSGB : serviceModel.modelDateType === 'b3dm' ? ModelType.B3DM : serviceModel.type === 2 ? ModelType.SWMX : ModelType.SWMX,
+  type:
+    serviceModel.modelDateType === "osgb"
+      ? ModelType.OSGB
+      : serviceModel.modelDateType === "b3dm"
+      ? ModelType.B3DM
+      : serviceModel.type === 2
+      ? ModelType.SWMX
+      : ModelType.SWMX,
   size: serviceModel.modelSize,
-  time: serviceModel.createTime
-})
+  time: serviceModel.createTime,
+});
 
 const localToService = (model: Model): ServiceModel => ({
   createTime: model.time,
@@ -86,48 +94,45 @@ const localToService = (model: Model): ServiceModel => ({
   hide: Number(!model.show),
   fusionId: model.fusionId,
   modelDateType: model.type,
-  modelUrl: model.url.join(','),
+  modelUrl: model.url.join(","),
   type: model.type === ModelType.SWSS ? 2 : 3,
   modelSize: model.size,
   modelTitle: model.title,
   opacity: model.opacity,
   bottom: model.bottom,
   transform: {
-    position: model.position, 
-    rotation: model.rotation, 
-    scale: [model.scale, model.scale, model.scale]
-  }
-})
+    position: model.position,
+    rotation: model.rotation,
+    scale: [model.scale, model.scale, model.scale],
+  },
+});
 
-export type Models = Model[]
+export type Models = Model[];
 
 export const fetchModels = async () => {
-  const serviceModels = await axios.post<ServiceModel[]>(MODEL_LIST)
-  return serviceModels.map(serviceToLocal)
-} 
+  const serviceModels = await axios.post<ServiceModel[]>(MODEL_LIST);
+  return serviceModels.map(serviceToLocal);
+};
 
 export const postAddModel = async (file: File) => {
-  const form = new FormData()
-  form.append('file', file)
+  const form = new FormData();
+  form.append("file", file);
 
   const serviceModel = await axios<ServiceModel>({
     url: INSERT_MODEL,
-    method: 'POST',
+    method: "POST",
     headers: { ...UPLOAD_HEADS },
-    data: form
-  })
-  return serviceToLocal(serviceModel)
-}
+    data: form,
+  });
+  return serviceToLocal(serviceModel);
+};
 
 export const postUpdateModels = (model: Model) => {
-  console.log('update', model)
-  return axios.post<undefined>(UPDATE_MODEL, localToService(model))
-}
-  
-
-export const postDeleteModel = (id: Model['id']) => {
-  console.log('delete')
-  return axios.post<undefined>(DELETE_MODEL, {ids: [id]})
-}
+  console.log("update", model);
+  return axios.post<undefined>(UPDATE_MODEL, localToService(model));
+};
 
-  
+export const postDeleteModel = (id: Model["id"]) => {
+  console.log("delete");
+  return axios.post<undefined>(DELETE_MODEL, { ids: [id] });
+};

+ 35 - 35
src/components/bill-ui/components/cropper/cropper.vue

@@ -1,39 +1,37 @@
 <template>
-  <Confirm title="裁剪" :func="clickHandler">
+  <Confirm :title="$t('sys.crop')" :func="clickHandler">
     <template v-slot:content>
       <div class="cropper-layer" :style="style">
-        <VueCropper 
-        ref="vmRef" 
-        v-bind="option"
-        v-on="on" />
+        <VueCropper ref="vmRef" v-bind="option" v-on="on" />
       </div>
     </template>
   </Confirm>
 </template>
 
 <script setup>
-import { VueCropper } from 'vue-cropper'
-import Confirm from '../dialog/Confirm.vue'
-import { computed, defineProps, ref } from 'vue'
+import { VueCropper } from "vue-cropper";
+import Confirm from "../dialog/Confirm.vue";
+import { computed, defineProps, ref } from "vue";
+import { ui18n } from "@/lang";
 // import 'vue-cropper/dist/index.css'
 
-const layerWidth = 500
+const layerWidth = 500;
 const props = defineProps({
   fixedNumber: {
     type: Array,
-    default: () => [1, 1]
+    default: () => [1, 1],
   },
   img: { type: String },
   cb: {
-    type: Function
-  }
-})
+    type: Function,
+  },
+});
 
-const fixedNumber = props.fixedNumber
-const getHeight = width => (fixedNumber[1] / fixedNumber[0]) * width
+const fixedNumber = props.fixedNumber;
+const getHeight = (width) => (fixedNumber[1] / fixedNumber[0]) * width;
 const option = {
   outputSize: 1,
-  outputType: 'png',
+  outputType: "png",
   info: false,
   full: true,
   fixed: true,
@@ -46,37 +44,36 @@ const option = {
   autoCropWidth: layerWidth / 2,
   autoCropHeight: getHeight(layerWidth / 2),
   centerBox: true,
-  mode: 'contain',
+  mode: "contain",
   maxImgSize: 400,
-  ...props
-}
+  ...props,
+};
 
 const style = computed(() => ({
-  width: layerWidth + 'px',
-  height: getHeight(layerWidth) + 'px'
-}))
+  width: layerWidth + "px",
+  height: getHeight(layerWidth) + "px",
+}));
 
-const vmRef = ref()
+const vmRef = ref();
 const on = {
   imgLoad(status) {
-    if (status !== 'success') {
-      props.cb('图片加载失败')
+    if (status !== "success") {
+      props.cb(ui18n.t("sys.imgloaderr"));
     }
   },
-}
+};
 
 const clickHandler = async (status) => {
-  if (status === 'ok') {
+  if (status === "ok") {
     const data = await Promise.all([
-      new Promise(resolve => vmRef.value.getCropBlob(resolve)),
-      new Promise(resolve => vmRef.value.getCropData(resolve)),
-    ])
-    props.cb(null, data)
+      new Promise((resolve) => vmRef.value.getCropBlob(resolve)),
+      new Promise((resolve) => vmRef.value.getCropData(resolve)),
+    ]);
+    props.cb(null, data);
   } else {
-    props.cb()
+    props.cb();
   }
-  
-}
+};
 </script>
 
 <style>
@@ -87,9 +84,12 @@ const clickHandler = async (status) => {
 .cropper-view-box {
   outline-color: var(--color-main-normal) !important;
 }
+
 .crop-point {
   background-color: var(--color-main-normal) !important;
 }
 </style>
 
-<script> export default { name: 'ui-cropper' } </script>
+<script>
+export default { name: "ui-cropper" };
+</script>

+ 3 - 2
src/components/bill-ui/components/cropper/index.js

@@ -1,18 +1,19 @@
 import Cropper from './cropper.vue'
 import { mount } from '../../utils/componentHelper'
 import { toRawType } from '../../utils/index'
+import { ui18n } from '@/lang'
 
 Cropper.use = function use(app) {
     const isCropper = false
     Cropper.open = function (config) {
         if (isCropper) {
-            return Promise.reject('正在裁剪')
+            return Promise.reject(ui18n.t('sys.cropIng'))
         }
         if (toRawType(config) === 'String') {
             config = { img: config }
         }
         if (!config || !config.img) {
-            return Promise.reject('请传入裁剪图片')
+            return Promise.reject(ui18n.t('sys.plcCropImg'))
         }
 
         return new Promise((resolve, reject) => {

+ 13 - 12
src/components/bill-ui/components/dialog/Alert.vue

@@ -11,18 +11,19 @@
     </ui-dialog>
 </template>
 <script>
-import { defineComponent } from 'vue'
-import { isFunction, omit } from '../../utils'
+import { defineComponent } from "vue";
+import { isFunction, omit } from "../../utils";
+import { ui18n } from "@/lang";
 export default defineComponent({
-    name: 'ui-alert',
+    name: "ui-alert",
     props: {
         title: {
             type: String,
-            default: '提示',
+            default: ui18n.t('sys.tip'),
         },
         okText: {
             type: String,
-            default: '确定',
+            default: ui18n.t('sys.enter'),
         },
         func: Function,
         content: String,
@@ -31,14 +32,14 @@ export default defineComponent({
     setup: function (props, ctx) {
         const close = () => {
             if (isFunction(props.func) && props.func() === false) {
-                return
+                return;
             }
-            isFunction(props.destroy) && props.destroy()
-        }
+            isFunction(props.destroy) && props.destroy();
+        };
         return {
-            ...omit(props, 'destroy', 'func'),
+            ...omit(props, "destroy", "func"),
             close,
-        }
+        };
     },
-})
-</script>
+});
+</script>

+ 14 - 13
src/components/bill-ui/components/dialog/Confirm.vue

@@ -25,38 +25,39 @@
     </ui-dialog>
 </template>
 <script>
-import { defineComponent } from 'vue'
-import { isFunction, omit } from '../../utils'
+import { defineComponent } from "vue";
+import { isFunction, omit } from "../../utils";
+import { ui18n } from "@/lang";
 export default defineComponent({
-    name: 'ui-confirm',
+    name: "ui-confirm",
     props: {
         title: {
             type: String,
-            default: '提示',
+            default: ui18n.t("sys.tip"),
         },
         okText: {
             type: String,
-            default: '确定',
+            default: ui18n.t("sys.enter"),
         },
         noText: {
             type: String,
-            default: '取消',
+            default: ui18n.t("sys.cancel"),
         },
         func: Function,
         content: String,
         destroy: Function,
     },
     setup: function (props, ctx) {
-        const close = result => {
+        const close = (result) => {
             if (isFunction(props.func) && props.func(result) === false) {
-                return
+                return;
             }
-            isFunction(props.destroy) && props.destroy()
-        }
+            isFunction(props.destroy) && props.destroy();
+        };
         return {
-            ...omit(props, 'destroy', 'func'),
+            ...omit(props, "destroy", "func"),
             close,
-        }
+        };
     },
-})
+});
 </script>

+ 195 - 165
src/components/bill-ui/components/input/file.vue

@@ -1,194 +1,224 @@
 <template>
-    <div class="input file" :class="{ suffix: $slots.icon, disabled: disabled, valuable }">
-        <template v-if="valuable">
-            <slot name="valuable" :key="modelValue" />
-        </template>
-        <input class="ui-text" type="file" ref="inputRef" :accept="accept" :multiple="multiple" @change="selectFileHandler" v-if="!maxLen || maxLen > modelValue.length" />
-        <template v-if="!$slots.replace">
-            <span class="replace">
-                <div class="placeholder" v-if="!valuable">
-                    <p><ui-icon type="add" /></p>
-                    <p>{{ placeholder }}</p>
-                    <p class="bottom">
-                        <template v-if="!othPlaceholder">
-                            <template v-if="accept">支持 {{ accept }} 等格式,</template>
-                            <template v-if="normalizeScale">宽*高比例 {{ scale }},</template>
-                            <template v-if="maxSize">大小不超过 {{ sizeStr }}{{ maxLen ? ',' : '' }}</template>
-                            <template v-if="maxLen">个数不超过 {{ maxLen }}个</template>
-                        </template>
-                        <template v-else>
-                            {{ othPlaceholder }}
-                        </template>
-                    </p>
-                </div>
-
-                <span v-else v-if="!maxLen || maxLen > modelValue.length">
-                    {{ multiple ? '继续添加' : '替换' }}
-                </span>
-                <span class="tj" v-if="maxLen && modelValue.length">
-                    <span>{{ modelValue.length || 0 }}</span> / {{ maxLen }}
-                </span>
-            </span>
-        </template>
-        <div class="use-replace" v-else>
-            <slot name="replace" />
+  <div class="input file" :class="{ suffix: $slots.icon, disabled: disabled, valuable }">
+    <template v-if="valuable">
+      <slot name="valuable" :key="modelValue" />
+    </template>
+    <input
+      class="ui-text"
+      type="file"
+      ref="inputRef"
+      :accept="accept"
+      :multiple="multiple"
+      @change="selectFileHandler"
+      v-if="!maxLen || maxLen > modelValue.length"
+    />
+    <template v-if="!$slots.replace">
+      <span class="replace">
+        <div class="placeholder" v-if="!valuable">
+          <p><ui-icon type="add" /></p>
+          <p>{{ placeholder }}</p>
+          <p class="bottom">
+            <template v-if="!othPlaceholder">
+              <template v-if="accept">{{ $t("sys.acceptTip", { accept }) }} </template>
+              <template v-if="normalizeScale"
+                >{{ $t("sys.scaleTip", { scale }) }}
+              </template>
+              <template v-if="maxSize">{{ $t("sys.sizeTip", { sizeStr }) }} </template>
+              <template v-if="maxLen">{{ $t("sys.countTip", { maxLen }) }} </template>
+            </template>
+            <template v-else>
+              {{ othPlaceholder }}
+            </template>
+          </p>
         </div>
+
+        <span v-else v-if="!maxLen || maxLen > modelValue.length">
+          {{ multiple ? $t("sys.continueAdd") : $t("sys.rep") }}
+        </span>
+        <span class="tj" v-if="maxLen && modelValue.length">
+          <span>{{ modelValue.length || 0 }}</span> / {{ maxLen }}
+        </span>
+      </span>
+    </template>
+    <div class="use-replace" v-else>
+      <slot name="replace" />
     </div>
+  </div>
 </template>
 
 <script setup>
-import { filePropsDesc } from './state'
-import { toRawType } from '../../utils'
-import Message from '../message'
-import { defineProps, defineEmits, defineExpose, ref, computed } from 'vue'
+import { filePropsDesc } from "./state";
+import { toRawType } from "../../utils";
+import Message from "../message";
+import { defineProps, defineEmits, defineExpose, ref, computed } from "vue";
+import { ui18n } from "@/lang";
 
 const props = defineProps({
-    ...filePropsDesc,
-})
-const emit = defineEmits(['update:modelValue'])
-const inputRef = ref(null)
+  ...filePropsDesc,
+});
+const emit = defineEmits(["update:modelValue"]);
+const inputRef = ref(null);
 const normalizeScale = computed(() => {
-    if (props.scale) {
-        const [w, h] = props.scale.split(':')
-        if (Number(w) && Number(h)) {
-            return [Number(w), Number(h)]
-        }
+  if (props.scale) {
+    const [w, h] = props.scale.split(":");
+    if (Number(w) && Number(h)) {
+      return [Number(w), Number(h)];
     }
-})
+  }
+});
 
-const valuable = computed(() => (Array.isArray(props.modelValue) ? props.modelValue.length : !!props.modelValue))
+const valuable = computed(() =>
+  Array.isArray(props.modelValue) ? props.modelValue.length : !!props.modelValue
+);
 const sizeStr = computed(() => {
-    if (props.maxSize) {
-        const mb = props.maxSize / 1024 / 1024
-        if (mb > 1024) {
-            return mb / 1024 + 'GB'
-        } else {
-            return mb + 'MB'
-        }
+  if (props.maxSize) {
+    const mb = props.maxSize / 1024 / 1024;
+    if (mb > 1024) {
+      return mb / 1024 + "GB";
+    } else {
+      return mb + "MB";
     }
-})
+  }
+});
 
 const supports = {
-    image: {
-        types: ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'],
-        preview(file, url) {
-            return new Promise((resolve, reject) => {
-                const img = new Image()
-                img.onload = () => resolve([img.width, img.height, file])
-                img.onerror = reject
-                img.src = url
-            })
-        },
+  image: {
+    types: ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"],
+    preview(file, url) {
+      return new Promise((resolve, reject) => {
+        const img = new Image();
+        img.onload = () => resolve([img.width, img.height, file]);
+        img.onerror = reject;
+        img.src = url;
+      });
     },
-    video: {
-        types: ['video/mp4'],
-        preview(file, url) {
-            return new Promise((resolve, reject) => {
-                const video = document.createElement('video')
-                video.preload = 'metadata'
-                video.onloadedmetadata = () => resolve([video.videoWidth, video.videoHeight, file])
-                video.onerror = reject
-                video.src = url
-            })
-        },
+  },
+  video: {
+    types: ["video/mp4"],
+    preview(file, url) {
+      return new Promise((resolve, reject) => {
+        const video = document.createElement("video");
+        video.preload = "metadata";
+        video.onloadedmetadata = () =>
+          resolve([video.videoWidth, video.videoHeight, file]);
+        video.onerror = reject;
+        video.src = url;
+      });
     },
-}
-
-const producePreviews = files =>
-    Promise.all(
-        files.map(
-            file =>
-                new Promise((resolve, reject) => {
-                    const fr = new FileReader()
-                    fr.onloadend = e => resolve(e.target.result)
-                    fr.onerror = e => loaderror(file, reject(e))
-                    fr.readAsDataURL(file)
-                })
-        )
+  },
+};
+
+const producePreviews = (files) =>
+  Promise.all(
+    files.map(
+      (file) =>
+        new Promise((resolve, reject) => {
+          const fr = new FileReader();
+          fr.onloadend = (e) => resolve(e.target.result);
+          fr.onerror = (e) => loaderror(file, reject(e));
+          fr.readAsDataURL(file);
+        })
     )
-
-const calcScale = (w, h) => parseInt((w / h) * 1000)
-
-const selectFileHandler = async ev => {
-    const fileEl = ev.target
-    const files = Array.from(fileEl.files)
-    const previewError = (e, msg = `预览加载失败!`) => {
-        console.error(e)
-        Message.error(msg)
-        fileEl.value = ''
-    }
-
-    if (props.accept) {
-        for (const file of files) {
-            const accepts = props.accept.split(',').map(atom => atom.trim())
-            const hname = file.name.substr(file.name.lastIndexOf('.'))
-            if (!accepts.includes(hname)) {
-                return previewError('格式错误', `仅支持${props.accept}格式文件`)
-            }
-        }
+  );
+
+const calcScale = (w, h) => parseInt((w / h) * 1000);
+
+const selectFileHandler = async (ev) => {
+  const fileEl = ev.target;
+  const files = Array.from(fileEl.files);
+  const previewError = (e, msg = ui18n.t("sys.previewError")) => {
+    console.error(e);
+    Message.error(msg);
+    fileEl.value = "";
+  };
+
+  if (props.accept) {
+    for (const file of files) {
+      const accepts = props.accept.split(",").map((atom) => atom.trim());
+      const hname = file.name.substr(file.name.lastIndexOf("."));
+      if (!accepts.includes(hname)) {
+        return previewError(ui18n.t("sys.gsError"), ui18n.t("sys.gsAcceptTip", props));
+      }
     }
-
-    let previews
-    if (props.preview || normalizeScale.value) {
-        try {
-            previews = await producePreviews(files)
-        } catch (e) {
-            return previewError(e)
-        }
+  }
+
+  let previews;
+  if (props.preview || normalizeScale.value) {
+    try {
+      previews = await producePreviews(files);
+    } catch (e) {
+      return previewError(e);
     }
-
-    if (normalizeScale.value) {
-        const sizesConfirm = []
-        for (let i = 0; i < files.length; i++) {
-            const support = Object.values(supports).find(support => support.types.includes(files[i].type))
-            if (support) {
-                sizesConfirm.push(support.preview(files[i], previews[i]))
-            }
-        }
-
-        let sizes
-        try {
-            sizes = await Promise.all(sizesConfirm)
-        } catch (e) {
-            return previewError(e)
-        }
-
-        for (const [w, h, file] of sizes) {
-            const scaleDiff = calcScale(...normalizeScale.value) - calcScale(w, h)
-
-            if (Math.abs(scaleDiff) > 300) {
-                return previewError('error scale', `${file.name}的比例部位不为${props.scale}`)
-            }
-        }
+  }
+
+  if (normalizeScale.value) {
+    const sizesConfirm = [];
+    for (let i = 0; i < files.length; i++) {
+      const support = Object.values(supports).find((support) =>
+        support.types.includes(files[i].type)
+      );
+      if (support) {
+        sizesConfirm.push(support.preview(files[i], previews[i]));
+      }
     }
 
-    if (props.maxSize) {
-        for (const file of files) {
-            if (file.size > props.maxSize) {
-                return previewError('error size', `${file.name}的大小超过${sizeStr.value}`)
-            }
-        }
+    let sizes;
+    try {
+      sizes = await Promise.all(sizesConfirm);
+    } catch (e) {
+      return previewError(e);
     }
 
-    const value = props.modelValue ? (props.multiple ? (toRawType(props.modelValue) === 'Array' ? props.modelValue : [props.modelValue]) : null) : props.multiple ? [] : null
-
-    const emitData = props.multiple
-        ? props.preview
-            ? [...value, ...files.map((file, i) => ({ file, preview: previews[i] }))]
-            : [...value, files]
-        : props.preview
-        ? { file: files[0], preview: previews[0] }
-        : files[0]
+    for (const [w, h, file] of sizes) {
+      const scaleDiff = calcScale(...normalizeScale.value) - calcScale(w, h);
 
-    if (Array.isArray(emitData) && props.maxLen && emitData.length > props.maxLen) {
-        return previewError('err len', `最多仅支持${props.maxLen}个文件!`)
+      if (Math.abs(scaleDiff) > 300) {
+        return previewError(
+          "error scale",
+          ui18n.t("sys.gsScaleTip", { ...props, name: file.name })
+        );
+      }
     }
-
-    emit('update:modelValue', emitData)
-    fileEl.value = ''
-}
+  }
+
+  if (props.maxSize) {
+    for (const file of files) {
+      if (file.size > props.maxSize) {
+        return previewError(
+          "error size",
+          ui18n.t("sys.gsSizeTip", { sizeStr: sizeStr.value, name: file.name })
+        );
+      }
+    }
+  }
+
+  const value = props.modelValue
+    ? props.multiple
+      ? toRawType(props.modelValue) === "Array"
+        ? props.modelValue
+        : [props.modelValue]
+      : null
+    : props.multiple
+    ? []
+    : null;
+
+  const emitData = props.multiple
+    ? props.preview
+      ? [...value, ...files.map((file, i) => ({ file, preview: previews[i] }))]
+      : [...value, files]
+    : props.preview
+    ? { file: files[0], preview: previews[0] }
+    : files[0];
+
+  if (Array.isArray(emitData) && props.maxLen && emitData.length > props.maxLen) {
+    return previewError("error len", ui18n.t("sys.gsCountTip", props));
+  }
+
+  emit("update:modelValue", emitData);
+  fileEl.value = "";
+};
 
 defineExpose({
-    input: inputRef,
-})
+  input: inputRef,
+});
 </script>

+ 8 - 6
src/components/bill-ui/components/input/state.js

@@ -1,3 +1,5 @@
+import { ui18n } from "@/lang"
+
 const instalcePublic = {
     name: {
         type: String,
@@ -11,7 +13,7 @@ const instalcePublic = {
     },
     placeholder: {
         require: false,
-        default: '请输入',
+        default: ui18n.t('sys.plcInput'),
     },
 }
 
@@ -31,7 +33,7 @@ export const filePropsDesc = {
     ...instalcePublic,
     placeholder: {
         require: false,
-        default: '请选择',
+        default: ui18n.t('sys.plcSelect'),
     },
     othPlaceholder: {
         require: false,
@@ -89,7 +91,7 @@ export const textPropsDesc = {
     },
     placeholder: {
         type: String,
-        default: '请输入',
+        default: ui18n.t('sys.plcInput'),
     },
     readonly: {
         type: Boolean,
@@ -137,8 +139,8 @@ export const selectPropsDesc = {
         type: Boolean,
         require: false,
     },
-    placeholder: { ...textPropsDesc.placeholder, default: '请选择' },
-    unplaceholder: { ...textPropsDesc.placeholder, default: '暂无选项' },
+    placeholder: { ...textPropsDesc.placeholder, default: ui18n.t('sys.plcSelect') },
+    unplaceholder: { ...textPropsDesc.placeholder, default: ui18n.t('sys.inSelect') },
     options: {
         type: Array,
         default: () => [],
@@ -151,7 +153,7 @@ export const selectPropsDesc = {
 
 export const searchPropsDesc = {
     ...selectPropsDesc,
-    unplaceholder: { ...textPropsDesc.placeholder, default: '无搜索结果' },
+    unplaceholder: { ...textPropsDesc.placeholder, default: ui18n.t('sys.unSearch') },
 }
 
 export const numberPropsDesc = {

+ 14 - 14
src/components/error/index.vue

@@ -1,28 +1,28 @@
 <template>
-    <div class="layout err-layout">
-        <div>
-            <img :src="errImg" />
-            <div class="content" v-html="'内存不足,请勿同时打开多个页面或应用程序,尝试重启浏览器后重新打开。'"></div>
-        </div>
+  <div class="layout err-layout">
+    <div>
+      <img :src="errImg" />
+      <div class="content" v-html="$t('sys.err')"></div>
     </div>
+  </div>
 </template>
 
 <script setup lang="ts">
-import errImg from './img/err.png'
+import errImg from "./img/err.png";
 
-console.log(errImg)
+console.log(errImg);
 </script>
 
 <style lang="scss" scoped src="./style.scss" />
 <style lang="scss">
 .err-layout {
-    .content {
-        font-weight: 400;
-        font-size: 16px;
-        color: rgba(100, 101, 102, 1);
-        p {
-            margin-top: 10px;
-        }
+  .content {
+    font-weight: 400;
+    font-size: 16px;
+    color: rgba(100, 101, 102, 1);
+    p {
+      margin-top: 10px;
     }
+  }
 }
 </style>

+ 11 - 12
src/components/list/index.vue

@@ -1,16 +1,16 @@
 <template>
   <ul class="list">
     <li class="header">
-      <h3>数据列表</h3>
+      <h3>{{ $t("sys.merge.list") }}</h3>
       <div class="action" v-if="$slots.action">
         <slot name="action"></slot>
       </div>
     </li>
     <ul class="content">
-      <li 
-        v-for="(item, i) in data" 
+      <li
+        v-for="(item, i) in data"
         :key="(item as any)['raw']?.id"
-        :class="{select: item.select}"
+        :class="{ select: item.select }"
         @click="$emit('changeSelect', item)"
       >
         <div class="atom-content">
@@ -22,17 +22,17 @@
 </template>
 
 <script lang="ts" setup>
-type Item = Record<string, any> & {select?: boolean}
-type ListProps = { title: string, key?: string, data: Array<Item>}
+type Item = Record<string, any> & { select?: boolean };
+type ListProps = { title: string; key?: string; data: Array<Item> };
 
-defineProps<ListProps>()
+defineProps<ListProps>();
 
-defineEmits<{ (e: 'changeSelect', item: Item): void }>()
+defineEmits<{ (e: "changeSelect", item: Item): void }>();
 </script>
 
 <style lang="scss" scoped>
 .header {
-  border-bottom: 1px solid rgba(255,255,255,0.16);
+  border-bottom: 1px solid rgba(255, 255, 255, 0.16);
   display: flex;
   justify-content: space-between;
   padding: 20px;
@@ -50,12 +50,12 @@ defineEmits<{ (e: 'changeSelect', item: Item): void }>()
     cursor: pointer;
 
     &.select {
-      background: rgba(0,200,175,0.1600);
+      background: rgba(0, 200, 175, 0.16);
     }
 
     .atom-content {
       padding: 20px 0;
-      border-bottom: 1px solid rgba(255,255,255,0.16);
+      border-bottom: 1px solid rgba(255, 255, 255, 0.16);
     }
   }
 }
@@ -64,4 +64,3 @@ defineEmits<{ (e: 'changeSelect', item: Item): void }>()
   list-style: none;
 }
 </style>
-

+ 5 - 5
src/components/tagging/sign.vue

@@ -24,10 +24,10 @@
       >
         <h2>{{ tagging.title }} </h2>
         <div class="content">
-          <p><span>特征描述:</span><div v-html="tagging.desc"></div></p>
-          <p><span>遗留部位:</span>{{ tagging.part }}</p>
-          <p><span>提取方法:</span>{{ tagging.method }}</p>
-          <p><span>提取人:</span>{{ tagging.principal }}</p>
+          <p><span>{{$t('sys.tagging.hot.desc')}}</span><div v-html="tagging.desc"></div></p>
+          <p><span>{{$t('sys.tagging.hot.part')}}</span>{{ tagging.part }}</p>
+          <p><span>{{$t('sys.tagging.hot.method')}}</span>{{ tagging.method }}</p>
+          <p><span>{{$t('sys.tagging.hot.principal')}}</span>{{ tagging.principal }}</p>
         </div>
         <Images 
           :tagging="tagging" 
@@ -37,7 +37,7 @@
         <div class="edit-hot" v-if="router.currentRoute.value.name === RoutesName.tagging">
           <span @click="$emit('delete')" class="fun-ctrl">
             <ui-icon type="del" />
-            删除
+            {{ $t('sys.del') }}
           </span>
         </div>
       </UIBubble>

+ 0 - 1
src/env/index.ts

@@ -30,7 +30,6 @@ export const custom = flatStacksValue({
   showTaggingPositions: showTaggingPositionsStack
 })
 
-
 export const params = strToParams(location.search) as unknown as Params
 params.fushId = Number(params.fushId)
 export type Params = { 

+ 16 - 0
src/lang/constant.ts

@@ -0,0 +1,16 @@
+// 语言支持
+export enum langNameEum {
+  zh = 'zh',
+  en = 'en',
+  jp = 'ja'
+}
+
+export const langNameDescs = {
+  [langNameEum.zh]: '中文',
+  [langNameEum.en]: 'English',
+  [langNameEum.jp]: '日本語'
+}
+
+export const deflangName = langNameEum.zh
+
+export const langNames = [langNameEum.en, langNameEum.zh, langNameEum.jp]

+ 10 - 0
src/lang/en/index.ts

@@ -0,0 +1,10 @@
+
+import { modulesParse } from '../helper'
+
+const { ctx, promise } = modulesParse(
+  import.meta.glob(['./*.ts', '!./index.ts'])
+)
+
+export default ctx
+export { promise }
+

+ 87 - 0
src/lang/en/sys.ts

@@ -0,0 +1,87 @@
+export default {
+    "TOKEN_INVALID": "token已失效",
+    "SUCCESS": "请求成功",
+    "SWKK": "四维看看",
+    "SWKJ": "四维看见",
+    "SWSS": "四维深时",
+    "SWMX": "三维模型",
+    "OSGB": "osgb模型",
+    "B3DM": "b3dm模型",
+    "tip": "提示",
+    "enter": "确定",
+    "cancel": "取消",
+    "save": "保存",
+    "quit": "退出",
+    "crop": "裁剪",
+    "cropIng": "正在裁剪",
+    "plcCropImg": "请传入裁剪图片",
+    "imgloaderr": "图片加载失败",
+    "edit": "编辑",
+    "del": "删除",
+    "add": "新增",
+    "search": "搜索",
+    "delConfirm": "确定要删除此数据吗?",
+    "tagging": {
+        "errpos": "当前位置无法添加",
+        "name": "标注",
+        "fz": "放置:",
+        "hot": {
+            "plc": "请输入热点标题",
+            "desc": "特征描述:",
+            "part": "遗留部位:",
+            "method": "提取方法:",
+            "principal": "提取人:",
+            "upimg": "上传图片",
+            "plcimg": "支持JPG、PNG图片格式,单张不超过5MB,最多支持上传9张。",
+            "titerr": "标注标题必须填写!",
+            "imgerr": "至少上传一张图片!"
+        }
+    },
+    "merge": {
+        "resetConfirm": "确定恢复默认?此操作无法撤销",
+        "rotate": "旋转",
+        "move": "移动",
+        "reset": "恢复默认",
+        "opacity": "模型不透明度",
+        "bottom": "离地高度",
+        "scale": "等比缩放",
+        "joint": "拼接",
+        "type": "数据来源:",
+        "size": "数据大小:",
+        "time": "拍摄时间:",
+        "list": "数据列表",
+        "upload": "上传模型",
+        "uploadplc": "支持ZIP压缩包格式"
+    },
+    "guide": {
+        "list": "导览列表",
+        "addPath": "添加视角",
+        "length": "视频时长",
+        "clear": "清空画面",
+        "un": "暂无导览",
+        "delConfirm": "确定要删除此画面吗?",
+        "delAllConfirm": "确定要清空画面吗?",
+        "path": "路径",
+        "emptyErr": "无法保存空路径导览!"
+    },
+    "unSaveConfirm": "您有操作未保存,确定要退出吗?",
+    "modelTitle": "多元融合",
+    "title": "融合平台",
+    "err": "内存不足,请勿同时打开多个页面或应用程序,尝试重启浏览器后重新打开。",
+    "plcInput": "请输入",
+    "plcSelect": "请选择",
+    "inSelect": "暂无选项",
+    "unSearch": "无搜索结果",
+    "acceptTip": "支持 {accept} 等格式,",
+    "gsAcceptTip": "仅支持{accept}格式文件",
+    "scaleTip": "宽*高比例 {scale},",
+    "gsScaleTip": "{name}的比例不为{scale}",
+    "sizeTip": "大小不超过 {sizeStr}",
+    "gsSizeTip": "{name}的大小超过{sizeStr}",
+    "countTip": "个数不超过 {maxLen}个",
+    "gsCountTip": "最多仅支持{maxLen}个文件!",
+    "continueAdd": "继续添加",
+    "rep": "替换",
+    "previewError": "预览加载失败!",
+    "gsError": "格式错误"
+}

+ 41 - 0
src/lang/helper.ts

@@ -0,0 +1,41 @@
+import { reactive } from 'vue'
+
+export const modulesParse = (modules: Record<string, () => Promise<any>>) => {
+  const ctx: any = reactive({})
+  const requests = Promise.all(Object.values(modules).map(fn => fn()))
+
+  return {
+    ctx,
+    promise: requests.then((cts: any[]) => {
+      const keys = Object.keys(modules)
+      const lang = Object.fromEntries(keys.map((key, i) => [key, cts[i]]))
+      langMessageFactory(lang, 'lang', ctx)
+    })
+  }
+}
+
+export function langMessageFactory(
+  requireModules: { [key in string]: any },
+  prefix = 'lang',
+  lang: { [key in string]: any } = {}
+) {
+  const filenames = Object.keys(requireModules)
+
+  for (let filename of filenames) {
+    const langAtom = requireModules[filename].default
+
+    filename = filename.replace(`./${prefix}/`, '').replace(/^\.\//, '')
+    filename = filename.substring(0, filename.lastIndexOf('.'))
+
+    const locals = filename.split('/')
+    const langname = locals.pop()
+    let current = lang
+
+    for (const name of locals) {
+      lang[name] = current = lang[name] || {}
+    }
+    current[langname!] = langAtom
+  }
+
+  return lang
+}

+ 81 - 0
src/lang/index.ts

@@ -0,0 +1,81 @@
+import { App, WritableComputedRef, ref } from 'vue'
+import { createI18n, I18n as BaseI18n } from 'vue-i18n'
+import { deflangName, langNameEum, langNames, langNameDescs } from './constant'
+import { localGetFactory, localSetFactory } from '@/utils/store'
+import { paramsToStr, strToParams } from '@/utils/params'
+import zh, { promise as zhPromise } from './zh'
+import en, { promise as enPromise } from './en'
+import jp, { promise as jpPromise } from './ja'
+
+type I18n = BaseI18n & {
+  global: {
+    t: I18nGlobalTranslation
+    changeLang(langName: langNameEum, reload?: boolean): void
+    locale: WritableComputedRef<langNameEum>
+  }
+}
+
+export const loaded = ref(false)
+Promise.all([zhPromise, enPromise, jpPromise]).then(() => (loaded.value = true))
+const localKey = 'lang'
+const local = {
+  get: localGetFactory(str => {
+    if (str) {
+      return str as langNameEum
+    } else {
+      const langs = Object.keys(langNameDescs)
+      const defLang = window?.navigator?.language || 'en'
+      const navLang = langs.find(lang =>
+        new RegExp(`-?${lang}-?`).test(defLang)
+      )
+      return navLang || langNameEum.en
+    }
+  }),
+  set: localSetFactory((lang: langNameEum) => lang)
+}
+
+const params = strToParams(location.search)
+export const lang = (params.lang || local.get(localKey)) as langNameEum
+if (lang !== local.get(localKey)) {
+  local.set(localKey, lang)
+}
+
+const i18n: I18n = createI18n({
+  legacy: false,
+  fallbackLocale: deflangName,
+  availableLocales: langNames,
+  locale: lang,
+  sync: true,
+  silentTranslationWarn: true,
+  missingWarn: false,
+  silentFallbackWarn: true
+}) as I18n
+
+export const langs = {
+  [langNameEum.en]: en,
+  [langNameEum.zh]: zh,
+  [langNameEum.jp]: jp
+}
+
+i18n.global.setLocaleMessage(langNameEum.zh, zh)
+i18n.global.setLocaleMessage(langNameEum.en, en)
+i18n.global.setLocaleMessage(langNameEum.jp, jp)
+i18n.global.changeLang = (lang: langNameEum, reload = true) => {
+  i18n.global.locale.value = lang
+  local.set(localKey, lang)
+  params.lang = lang
+  if (reload) {
+    location.search = paramsToStr(params)
+  }
+  // location.reload()
+}
+
+export const setupI18n = (app: App) => {
+  app.config.globalProperties.$t = i18n.global.t
+  app.use(i18n)
+}
+export const changeLang = i18n.global.changeLang
+export const ui18n = i18n.global
+export const useI18n = () => ui18n
+
+export * from './constant'

+ 10 - 0
src/lang/ja/index.ts

@@ -0,0 +1,10 @@
+
+import { modulesParse } from '../helper'
+
+const { ctx, promise } = modulesParse(
+  import.meta.glob(['./*.ts', '!./index.ts'])
+)
+
+export default ctx
+export { promise }
+

+ 87 - 0
src/lang/ja/sys.ts

@@ -0,0 +1,87 @@
+export default {
+    "TOKEN_INVALID": "token已失效",
+    "SUCCESS": "请求成功",
+    "SWKK": "四维看看",
+    "SWKJ": "四维看见",
+    "SWSS": "四维深时",
+    "SWMX": "三维模型",
+    "OSGB": "osgb模型",
+    "B3DM": "b3dm模型",
+    "tip": "提示",
+    "enter": "确定",
+    "cancel": "取消",
+    "save": "保存",
+    "quit": "退出",
+    "crop": "裁剪",
+    "cropIng": "正在裁剪",
+    "plcCropImg": "请传入裁剪图片",
+    "imgloaderr": "图片加载失败",
+    "edit": "编辑",
+    "del": "删除",
+    "add": "新增",
+    "search": "搜索",
+    "delConfirm": "确定要删除此数据吗?",
+    "tagging": {
+        "errpos": "当前位置无法添加",
+        "name": "标注",
+        "fz": "放置:",
+        "hot": {
+            "plc": "请输入热点标题",
+            "desc": "特征描述:",
+            "part": "遗留部位:",
+            "method": "提取方法:",
+            "principal": "提取人:",
+            "upimg": "上传图片",
+            "plcimg": "支持JPG、PNG图片格式,单张不超过5MB,最多支持上传9张。",
+            "titerr": "标注标题必须填写!",
+            "imgerr": "至少上传一张图片!"
+        }
+    },
+    "merge": {
+        "resetConfirm": "确定恢复默认?此操作无法撤销",
+        "rotate": "旋转",
+        "move": "移动",
+        "reset": "恢复默认",
+        "opacity": "模型不透明度",
+        "bottom": "离地高度",
+        "scale": "等比缩放",
+        "joint": "拼接",
+        "type": "数据来源:",
+        "size": "数据大小:",
+        "time": "拍摄时间:",
+        "list": "数据列表",
+        "upload": "上传模型",
+        "uploadplc": "支持ZIP压缩包格式"
+    },
+    "guide": {
+        "list": "导览列表",
+        "addPath": "添加视角",
+        "length": "视频时长",
+        "clear": "清空画面",
+        "un": "暂无导览",
+        "delConfirm": "确定要删除此画面吗?",
+        "delAllConfirm": "确定要清空画面吗?",
+        "path": "路径",
+        "emptyErr": "无法保存空路径导览!"
+    },
+    "unSaveConfirm": "您有操作未保存,确定要退出吗?",
+    "modelTitle": "多元融合",
+    "title": "融合平台",
+    "err": "内存不足,请勿同时打开多个页面或应用程序,尝试重启浏览器后重新打开。",
+    "plcInput": "请输入",
+    "plcSelect": "请选择",
+    "inSelect": "暂无选项",
+    "unSearch": "无搜索结果",
+    "acceptTip": "支持 {accept} 等格式,",
+    "gsAcceptTip": "仅支持{accept}格式文件",
+    "scaleTip": "宽*高比例 {scale},",
+    "gsScaleTip": "{name}的比例不为{scale}",
+    "sizeTip": "大小不超过 {sizeStr}",
+    "gsSizeTip": "{name}的大小超过{sizeStr}",
+    "countTip": "个数不超过 {maxLen}个",
+    "gsCountTip": "最多仅支持{maxLen}个文件!",
+    "continueAdd": "继续添加",
+    "rep": "替换",
+    "previewError": "预览加载失败!",
+    "gsError": "格式错误"
+}

+ 253 - 0
src/lang/lang-editer/app.vue

@@ -0,0 +1,253 @@
+<template>
+  <p class="desc">被 {} 包裹的为占位符,在使用时会被实际参数占用,不可去除</p>
+  <div>
+    <RadioGroup v-model:value="currentLang" button-style="solid">
+      <RadioButton v-for="(_, name) in trees" :key="name" :value="name">{{
+        name
+      }}</RadioButton>
+    </RadioGroup>
+  </div>
+
+  <div :key="importCount" class="langs">
+    <template v-for="(tree, name) in trees">
+      <div v-if="name === currentLang" :key="name">
+        <Upload
+          :file-list="[]"
+          :max-size="100000000"
+          :extnames="undefined"
+          :multiple="false"
+          :before-upload="file => onBeforeUpload(name, file)"
+          accept=".xls"
+        >
+          <a>导入</a>
+        </Upload>
+        <a @click="exportFile(name)">导出</a>
+        <Tree :tree-data="tree" default-expand-all>
+          <template #title="{ dataRef }">
+            <template v-if="!dataRef.children">
+              {{ dataRef.label }} :
+              <Textarea
+                v-model:value="dataRef.title"
+                :placeholder="dataRef.label"
+                style="width: 300px"
+              />
+            </template>
+            <template v-else> {{ dataRef.title }} </template>
+          </template>
+        </Tree>
+      </div>
+    </template>
+  </div>
+
+  <Button block size="large" type="primary" class="btn" @click="emit('submit')"
+    >保存</Button
+  >
+</template>
+
+<script setup lang="ts">
+// import 'ant-design-vue/dist/antd.css'
+import * as XLSX from 'xlsx'
+import { Lang } from './main'
+import {
+  Tree,
+  Textarea,
+  Button,
+  Upload,
+  message,
+  RadioGroup,
+  RadioButton
+} from 'ant-design-vue'
+import { reactive, ref } from 'vue'
+import { saveAs } from '@/utils/file-serve'
+
+// console.log(utils)
+const props = defineProps<{ langs: Record<string, Lang> }>()
+const emit = defineEmits<{ (e: 'submit'): void }>()
+
+const normalLangTree = (lang: Lang, prefix = '') => {
+  return Object.entries(lang)
+    .map(([name, value]) => {
+      const key = prefix + name
+      if (typeof value === 'string') {
+        return reactive({
+          key,
+          label: name,
+          get title() {
+            return lang[name]
+          },
+          set title(value) {
+            lang[name] = value
+          }
+        })
+      } else {
+        return {
+          key,
+          label: '',
+          title: name,
+          children: normalLangTree(value, key)
+        }
+      }
+    })
+    .sort((a, b) => a.key.localeCompare(b.key))
+}
+
+const trees = Object.entries(props.langs).reduce((t, [name, lang]) => {
+  t[name] = normalLangTree(lang)
+  return t
+}, {})
+const currentLang = ref('zh')
+
+const onBeforeUpload = (name: string, file: File) => {
+  const index = file.name.lastIndexOf('.')
+  const ext = ~index ? file.name.substring(index + 1).toUpperCase() : null
+
+  if (!ext || ext !== 'XLS') {
+    message.error(`仅支持xls文件格式`)
+  } else {
+    let reader = new FileReader()
+    //读入file
+    reader.readAsBinaryString(file)
+    reader.onload = e => {
+      importFile(props.langs[name], e.target.result)
+    }
+  }
+  return false
+}
+
+const importCount = ref(0)
+function importFile(lang: Lang, buffer: string | ArrayBuffer) {
+  const workbook = XLSX.read(buffer, { type: 'binary' })
+  const data = workbook.Sheets[workbook.SheetNames[0]]
+  const keyKeys = Object.keys(data).filter(
+    k => k.charAt(0) === 'A' && Number(k.substring(1)) > 1
+  )
+
+  for (const keyKey of keyKeys) {
+    const valueKey = `B${keyKey.substring(1)}`
+    const value = data[valueKey]
+    const key = data[keyKey]
+    const langKey: string = key?.v
+    const langValue: string = value?.v
+
+    if (!langKey || !langValue) {
+      continue
+    }
+
+    const paths = langKey.split('.')
+    for (let i = 0, target = lang; i < paths.length; i++) {
+      const key = paths[i]
+      if (key in target) {
+        if (i === paths.length - 1) {
+          target[key] = langValue
+        } else if (typeof target[key] !== 'string') {
+          target = target[key] as Lang
+        }
+      } else {
+        break
+      }
+    }
+  }
+
+  importCount.value++
+}
+
+const getSheetData = (lang: Lang, prev = '') => {
+  const result = []
+  for (const [key, value] of Object.entries(lang)) {
+    const label = (prev ? `${prev}.` : '') + key
+    if (typeof value === 'string') {
+      result.push({
+        key: label,
+        value
+      })
+    } else {
+      result.push(...getSheetData(value, label))
+    }
+  }
+  return result
+}
+
+function exportFile(name) {
+  const sheetData = getSheetData(props.langs[name])
+  // 将由对象组成的数组转化成sheet
+  const sheet = XLSX.utils.json_to_sheet(sheetData)
+  // 百分数和数字更改为数字类型
+  Object.keys(sheet).forEach(key => {
+    if (sheet[key].v) {
+      const val = sheet[key].v
+      if (!isNaN(val)) {
+        sheet[key].t = 'n'
+      }
+      if (val.lastIndexOf('%') === val.length - 1) {
+        sheet[key].t = 'n'
+        sheet[key].z = '0.00%'
+        sheet[key].v = Number(val.substring(0, val.length - 1)) / 100
+      }
+    }
+  })
+  // 创建虚拟的workbook
+  const wb = XLSX.utils.book_new()
+  // 把sheet添加到workbook中
+  XLSX.utils.book_append_sheet(wb, sheet, name)
+  const workbookBlob = workbook2blob(wb)
+  saveAs(workbookBlob, `${name}.xls`)
+}
+
+function workbook2blob(workbook) {
+  // 生成excel的配置项
+  const wopts = {
+    // 要生成的文件类型
+    bookType: 'xlsx',
+    // // 是否生成Shared String Table,官方解释是,如果开启生成速度会下降,但在低版本IOS设备上有更好的兼容性
+    bookSST: false,
+    type: 'binary'
+  }
+  const wbout = XLSX.write(workbook, wopts as any)
+  // 将字符串转ArrayBuffer
+  function s2ab(s: string) {
+    const buf = new ArrayBuffer(s.length)
+    const view = new Uint8Array(buf)
+    for (let i = 0; i !== s.length; ++i) view[i] = s.charCodeAt(i) & 0xff
+    return buf
+  }
+  const blob = new Blob([s2ab(wbout)], {
+    type: 'application/octet-stream'
+  })
+  return blob
+}
+</script>
+
+<style scoped lang="scss">
+.desc {
+  text-align: center;
+  font-size: 1.5em;
+}
+
+h1 {
+  text-align: center;
+}
+
+.langs {
+  display: flex;
+  padding-bottom: 80px;
+}
+
+.lang {
+  flex: 1;
+  padding-left: 30px;
+
+  h2 {
+    text-align: center;
+    position: fixed;
+    top: 50%;
+    margin-left: -30px;
+    display: block;
+  }
+}
+
+.btn {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+}
+</style>

+ 52 - 0
src/lang/lang-editer/main.ts

@@ -0,0 +1,52 @@
+import { computed, createApp, watchEffect } from 'vue'
+import App from './app.vue'
+import axios from 'axios'
+import { langs, deflangName, loaded } from '@/lang'
+
+export type Lang = {
+  [key in string]: string | Lang
+}
+
+document.title = '国际化文件配置'
+
+const deflang = langs[deflangName]
+const normalLangs = (lang: any, standard: any) => {
+  lang = { ...lang }
+  for (const key in standard) {
+    const exists1 = key in lang
+    const exists2 = key in standard
+    const type1 = typeof lang[key]
+    const type2 = typeof standard[key]
+
+    if (exists1 !== exists2 || type1 !== type2) {
+      lang[key] = standard[key]
+    } else if (type1 !== 'string') {
+      lang[key] = normalLangs(lang[key], standard[key])
+    }
+  }
+  return lang
+}
+
+const stopWatch = watchEffect(() => {
+  if (loaded.value) {
+    const nlangs = Object.entries(langs)
+      .map(([name, value]) => [
+        name,
+        deflang === value ? value : normalLangs(value, deflang)
+      ])
+      .reduce((t, [name, lang]) => {
+        ;(t as any)[name] = lang
+        return t
+      }, {})
+
+    const app = createApp(App, {
+      langs: nlangs,
+      onSubmit() {
+        axios.post('/dev/langs', nlangs)
+        console.log(nlangs)
+      }
+    })
+    app.mount('#app')
+    stopWatch()
+  }
+})

+ 10 - 0
src/lang/zh/index.ts

@@ -0,0 +1,10 @@
+
+import { modulesParse } from '../helper'
+
+const { ctx, promise } = modulesParse(
+  import.meta.glob(['./*.ts', '!./index.ts'])
+)
+
+export default ctx
+export { promise }
+

+ 87 - 0
src/lang/zh/sys.ts

@@ -0,0 +1,87 @@
+export default {
+    "TOKEN_INVALID": "token已失效",
+    "SUCCESS": "请求成功",
+    "SWKK": "四维看看",
+    "SWKJ": "四维看见",
+    "SWSS": "四维深时",
+    "SWMX": "三维模型",
+    "OSGB": "osgb模型",
+    "B3DM": "b3dm模型",
+    "tip": "提示",
+    "enter": "确定",
+    "cancel": "取消",
+    "save": "保存",
+    "quit": "退出",
+    "crop": "裁剪",
+    "cropIng": "正在裁剪",
+    "plcCropImg": "请传入裁剪图片",
+    "imgloaderr": "图片加载失败",
+    "edit": "编辑",
+    "del": "删除",
+    "add": "新增",
+    "search": "搜索",
+    "delConfirm": "确定要删除此数据吗?",
+    "tagging": {
+        "errpos": "当前位置无法添加",
+        "name": "标注",
+        "fz": "放置:",
+        "hot": {
+            "plc": "请输入热点标题",
+            "desc": "特征描述:",
+            "part": "遗留部位:",
+            "method": "提取方法:",
+            "principal": "提取人:",
+            "upimg": "上传图片",
+            "plcimg": "支持JPG、PNG图片格式,单张不超过5MB,最多支持上传9张。",
+            "titerr": "标注标题必须填写!",
+            "imgerr": "至少上传一张图片!"
+        }
+    },
+    "merge": {
+        "resetConfirm": "确定恢复默认?此操作无法撤销",
+        "rotate": "旋转",
+        "move": "移动",
+        "reset": "恢复默认",
+        "opacity": "模型不透明度",
+        "bottom": "离地高度",
+        "scale": "等比缩放",
+        "joint": "拼接",
+        "type": "数据来源:",
+        "size": "数据大小:",
+        "time": "拍摄时间:",
+        "list": "数据列表",
+        "upload": "上传模型",
+        "uploadplc": "支持ZIP压缩包格式"
+    },
+    "guide": {
+        "list": "导览列表",
+        "addPath": "添加视角",
+        "length": "视频时长",
+        "clear": "清空画面",
+        "un": "暂无导览",
+        "delConfirm": "确定要删除此画面吗?",
+        "delAllConfirm": "确定要清空画面吗?",
+        "path": "路径",
+        "emptyErr": "无法保存空路径导览!"
+    },
+    "unSaveConfirm": "您有操作未保存,确定要退出吗?",
+    "modelTitle": "多元融合",
+    "title": "融合平台",
+    "err": "内存不足,请勿同时打开多个页面或应用程序,尝试重启浏览器后重新打开。",
+    "plcInput": "请输入",
+    "plcSelect": "请选择",
+    "inSelect": "暂无选项",
+    "unSearch": "无搜索结果",
+    "acceptTip": "支持 {accept} 等格式,",
+    "gsAcceptTip": "仅支持{accept}格式文件",
+    "scaleTip": "宽*高比例 {scale},",
+    "gsScaleTip": "{name}的比例不为{scale}",
+    "sizeTip": "大小不超过 {sizeStr}",
+    "gsSizeTip": "{name}的大小超过{sizeStr}",
+    "countTip": "个数不超过 {maxLen}个",
+    "gsCountTip": "最多仅支持{maxLen}个文件!",
+    "continueAdd": "继续添加",
+    "rep": "替换",
+    "previewError": "预览加载失败!",
+    "gsError": "格式错误"
+}

+ 8 - 16
src/layout/header/index.vue

@@ -6,15 +6,9 @@
 
     <div class="control">
       <template v-if="isEdit">
-        <ui-button width="105px" @click="leave">退出</ui-button>
-        <ui-button 
-          width="105px" 
-          type="primary" 
-          class="save" 
-          v-if="isOld"
-          @click="save"
-        >
-          保存
+        <ui-button width="105px" @click="leave">{{ $t("sys.quit") }}</ui-button>
+        <ui-button width="105px" type="primary" class="save" v-if="isOld" @click="save">
+          {{ $t("sys.save") }}
         </ui-button>
       </template>
     </div>
@@ -22,15 +16,13 @@
 </template>
 
 <script setup lang="ts">
-import { computed, watchEffect } from 'vue'
-import { isEdit, title, isOld, leave, save } from '@/store'
-
-const props = defineProps<{ title?: string }>()
-const sysTitle = computed(() => props.title || title.value)
-
-watchEffect(() => (document.title = sysTitle.value))
+import { computed, watchEffect } from "vue";
+import { isEdit, title, isOld, leave, save } from "@/store";
 
+const props = defineProps<{ title?: string }>();
+const sysTitle = computed(() => props.title || title.value);
 
+watchEffect(() => (document.title = sysTitle.value));
 </script>
 
 <style lang="sass" scoped>

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

@@ -1,30 +1,26 @@
 <template>
   <LeftPano>
-    <List 
-      title="数据列表" 
-      key="id" 
-      :data="modelList" 
-    >
+    <List :title="$t('sys.merge.list')" key="id" :data="modelList">
       <template #action v-if="custom.modelsChangeStore">
         <ui-input
           type="file"
           width="20px"
-          placeholder="上传模型"
-          othPlaceholder="支持ZIP压缩包格式"
+          :placeholder="$t('sys.merge.upload')"
+          :othPlaceholder="$t('sys.merge.uploadplc')"
           accept=".zip"
           :disable="true"
           :multiple="false"
           @update:modelValue="addHandler"
         >
           <template v-slot:replace>
-            <ui-icon type="add" ctrl/>
+            <ui-icon type="add" ctrl />
           </template>
-      </ui-input>
+        </ui-input>
       </template>
       <template #atom="{ item }">
-        <ModelSign 
-          :model="item.raw" 
-          @delete="modelDelete(item.raw)" 
+        <ModelSign
+          :model="item.raw"
+          @delete="modelDelete(item.raw)"
           @click="modelChangeSelect(item.raw)"
         />
       </template>
@@ -33,57 +29,56 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, watchEffect } from 'vue'
-import { LeftPano } from '@/layout'
-import { models, getModelShowVariable, addModel } from '@/store'
-import { custom } from '@/env'
-import { getSceneModel } from '@/sdk'
-import List from '@/components/list/index.vue'
-import ModelSign from './sign.vue'
+import { computed, watchEffect } from "vue";
+import { LeftPano } from "@/layout";
+import { models, getModelShowVariable, addModel } from "@/store";
+import { custom } from "@/env";
+import { getSceneModel } from "@/sdk";
+import List from "@/components/list/index.vue";
+import ModelSign from "./sign.vue";
 
-import type { Model } from '@/store'
+import type { Model } from "@/store";
 
-const modelList = computed(() => 
-  models.value.map(model => ({
+const modelList = computed(() =>
+  models.value.map((model) => ({
     raw: model,
-    select: custom.currentModel === model
+    select: custom.currentModel === model,
   }))
-)
+);
 
 const addHandler = async (file: File) => {
-  console.error('addHandler?')
-  await addModel(file)
-  modelList.value.forEach(model => {
+  console.error("addHandler?");
+  await addModel(file);
+  modelList.value.forEach((model) => {
     if (!custom.showModelsMap.has(model.raw)) {
-      custom.showModelsMap.set(model.raw, model.raw.show)
+      custom.showModelsMap.set(model.raw, model.raw.show);
     }
-  })
-}
+  });
+};
 
 const modelChangeSelect = (model: Model) => {
   if (getModelShowVariable(model).value) {
-    console.error('changeSelect',getSceneModel(model), custom.currentModel !== model)
+    console.error("changeSelect", getSceneModel(model), custom.currentModel !== model);
     if (custom.currentModel !== model) {
-      getSceneModel(model)?.changeSelect(true)
-      custom.currentModel = model
+      getSceneModel(model)?.changeSelect(true);
+      custom.currentModel = model;
     } else {
-      getSceneModel(custom.currentModel)?.changeSelect(false)
-      custom.currentModel = null
+      getSceneModel(custom.currentModel)?.changeSelect(false);
+      custom.currentModel = null;
     }
   }
-}
+};
 
 watchEffect(() => {
   if (custom.currentModel && !getModelShowVariable(custom.currentModel).value) {
-    custom.currentModel = null
+    custom.currentModel = null;
   }
-})
+});
 
 const modelDelete = (model: Model) => {
-  const index = models.value.indexOf(model)
+  const index = models.value.indexOf(model);
   if (~index) {
-    models.value.splice(index, 1)
+    models.value.splice(index, 1);
   }
-}
-
-</script>
+};
+</script>

+ 28 - 20
src/layout/model-list/sign.vue

@@ -2,40 +2,48 @@
   <div class="model-header" @click="!model.error && $emit('click')">
     <p>{{ model.title }}</p>
     <div class="model-action" @click.stop>
-      <ui-input type="checkbox" v-model="show" :class="{disabled: model.error}"/>
-      <ui-icon 
+      <ui-input type="checkbox" v-model="show" :class="{ disabled: model.error }" />
+      <ui-icon
         v-if="custom.modelsChangeStore && model.type !== ModelType.SWSS && !model.isSelf"
-        type="del" 
-        ctrl 
-        @click="$emit('delete')" 
+        type="del"
+        ctrl
+        @click="$emit('delete')"
       />
     </div>
   </div>
   <div class="model-desc" @click="$emit('click')" v-if="custom.currentModel === model">
-    <p><span>数据来源:</span>{{ ModelTypeDesc[model.type] }}</p>
-    <p><span>数据大小:</span>{{ model.size }}</p>
-    <p v-if="model.type === ModelType.SWSS"><span>拍摄时间:</span>{{ model.time }}</p>
+    <p>
+      <span>{{ $t("sys.merge.type") }}</span
+      >{{ ModelTypeDesc[model.type] }}
+    </p>
+    <p>
+      <span>{{ $t("sys.merge.size") }}</span
+      >{{ model.size }}
+    </p>
+    <p v-if="model.type === ModelType.SWSS">
+      <span>{{ $t("sys.merge.time") }}</span
+      >{{ model.time }}
+    </p>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { getModelShowVariable, ModelTypeDesc, ModelType } from '@/store'
-import { custom } from '@/env'
+import { getModelShowVariable, ModelTypeDesc, ModelType } from "@/store";
+import { custom } from "@/env";
 
-import type { Model } from '@/store'
+import type { Model } from "@/store";
 
-type ModelProps = { model: Model }
-const props = defineProps<ModelProps>()
+type ModelProps = { model: Model };
+const props = defineProps<ModelProps>();
 
 type ModelEmits = {
-  (e: 'changeSelect', selected: boolean): void
-  (e: 'delete'): void
-  (e: 'click'): void
-}
+  (e: "changeSelect", selected: boolean): void;
+  (e: "delete"): void;
+  (e: "click"): void;
+};
 defineEmits<ModelEmits>();
 
-const show = getModelShowVariable(props.model)
-
+const show = getModelShowVariable(props.model);
 </script>
 
-<style lang="scss" scoped src="./style.scss"></style>
+<style lang="scss" scoped src="./style.scss"></style>

+ 2 - 0
src/main.ts

@@ -3,10 +3,12 @@ import './style.scss'
 import App from './app.vue'
 import Components from 'bill/index'
 import router from './router'
+import { setupI18n, ui18n } from '@/lang'
 
 const app = createApp(App)
 app.use(Components)
 app.use(router)
+setupI18n(app)
 app.mount('#app')
 
 export default app

+ 5 - 3
src/router/constant.ts

@@ -1,3 +1,5 @@
+import { ui18n } from "@/lang"
+
 export enum RoutesName {
   merge = 'merge',
   tagging = 'tagging',
@@ -17,15 +19,15 @@ export type Meta = { title: string, icon: string }
 export const metas: RouteSeting<Meta> = {
   [RoutesName.merge]: {
     icon: 'joint',
-    title: '拼接'
+    title: ui18n.t('sys.merge.joint')
   },
   [RoutesName.tagging]: {
     icon: 'label',
-    title: '标注'
+    title: ui18n.t('sys.tagging.name')
   },
   [RoutesName.guide]: {
     icon: 'path',
-    title: '路径'
+    title: ui18n.t('sys.guide.path')
   }
 }
 

+ 3 - 2
src/store/guide.ts

@@ -19,6 +19,7 @@ import {
 
 import type { GuidePath as SGuidePath, Guide as SGuide } from '@/api'
 import { Dialog } from 'bill/index'
+import { ui18n } from '@/lang'
 
 export type GuidePath = LocalMode<SGuidePath, 'cover'>
 export type GuidePaths = GuidePath[]
@@ -30,7 +31,7 @@ export const guides = ref<Guides>([])
 
 export const createGuide = (guide: Partial<Guide> = {}): Guide => ({
   id: createTemploraryID(),
-  title: `路径${guides.value.length + 1}`,
+  title: `${ui18n.t('sys.guide.path')}${guides.value.length + 1}`,
   cover: '',
   paths: [],
   ...guide
@@ -100,7 +101,7 @@ export const autoSaveGuides = autoSetModeCallback(guides, {
   save: async () => {
     guides.value.forEach((guide) => {
       if (!guide.paths.length) {
-        Dialog.alert('无法保存空路径导览!')
+        Dialog.alert(ui18n.t('sys.guide.emptyErr'))
         throw '无法保存空路径导览!'
       }
 

+ 15 - 7
src/store/sys.ts

@@ -5,6 +5,7 @@ import { useViewStack } from '@/hook'
 
 import type { UnwrapRef } from 'vue'
 import { models, ModelType } from './model'
+import { ui18n } from '@/lang'
 
 const Flags = {
   EDIT: 0b10,
@@ -22,9 +23,9 @@ export const isNow = computed(() => !!(mode.value & Flags.NOW))
 export const title = computed(() => {
   const defaultModel = models.value.find(model => model.type === ModelType.SWSS)
   if (defaultModel) {
-    return defaultModel.title + ' | 多元融合'
+    return defaultModel.title + ' | '+ui18n.t('sys.modelTitle')
   } else {
-    return '融合平台'
+    return ui18n.t('sys.title')
   }
 }) 
 
@@ -68,7 +69,7 @@ export const save = async () => {
 
 // 离开
 export const leave = async () => {
-  if (isOld.value && !(await Dialog.confirm('您有操作未保存,确定要退出吗?'))) {
+  if (isOld.value && !(await Dialog.confirm(ui18n.t('sys.unSaveConfirm')))) {
     return;
   }
   await sysBus.emit('leave')
@@ -92,24 +93,31 @@ export const unSetModelUpdate = (run: () => void) => {
 }
 export const autoSetModeCallback = <T extends object>(current: T, setting: AutoSetModeSetting<T>) => {
   let isSave = false
-
+  let isBack = false
   const leaveCallback = (setting.recovery || setting.backup)
     && (() => {
+      isBack = true
       setting.recovery && setting.recovery()
       setting.backup && setting.backup()
       setting.leave && setting.leave()
+      setTimeout(() => isBack = false, 100)
     })
   
   const saveCallback = async () => {
-    leaveCallback && sysBus.off('leave', leaveCallback, { last: true })
     isSave = true
-    await setting.save()
+    try {
+      await setting.save()
+    } catch (e){
+      isSave = false
+      throw e
+    }
+    leaveCallback && sysBus.off('leave', leaveCallback, { last: true })
     setting.backup && setting.backup()
     isSave = false
   }
 
   const handler = (newv: UnwrapRef<T>, oldv?: UnwrapRef<T>) => {
-    if (isSave || isUnset) return
+    if (isSave || isUnset || isBack) return
     if (!setting.isUpdate || setting.isUpdate(newv, oldv)) {
       isEdit.value || enterEdit()
       isOld.value ||  enterOld()

+ 5 - 1
src/store/tagging.ts

@@ -48,8 +48,12 @@ export const createTagging = (tagging: Partial<Tagging> = {}): Tagging => ({
 
 
 let bcTaggings: Taggings = []
-export const getBackupTaggings = () => bcTaggings
+export const getBackupTaggings = () => {
+  console.error("get back?", bcTaggings)
+  return bcTaggings
+}
 export const backupTaggings = () => {
+  console.error("back?")
   bcTaggings = taggings.value.map(tagging => ({
     ...tagging,
     images: [...tagging.images],

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

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

+ 2 - 0
src/utils/index.ts

@@ -54,6 +54,8 @@ export const jsonToForm = (data: { [key in string]: any }) => {
   return formData
 }
 
+
+
 export * from './store-help'
 export * from "./stack";
 export * from "./loading";

+ 9 - 1
src/utils/params.ts

@@ -15,4 +15,12 @@ export const strToParams = (str: string) => {
   }
 
   return result
-}
+}
+
+// 对象转params
+export const paramsToStr = (params: { [key: string]: string | boolean }) =>
+  '?' +
+  Object.keys(params)
+    .filter(key => params[key] !== undefined)
+    .map(key => `${key}${params[key] == false ? '' : `=${params[key]}`}`)
+    .join('&')

+ 89 - 0
src/utils/store.ts

@@ -0,0 +1,89 @@
+type Store = typeof localStorage | typeof sessionStorage
+
+type SetTransform<T> = (args: T) => string
+type GetTransform<T> = (args: null | string) => T
+
+export function get(store: Store, name: string): string | null
+export function get<T>(
+  store: Store,
+  name: string,
+  transform: GetTransform<T>
+): T
+export function get<T>(
+  store: Store,
+  name: string,
+  transform?: GetTransform<T>
+) {
+  const value = store.getItem(name)
+  if (transform) {
+    return transform(value)
+  } else {
+    return value
+  }
+}
+
+export function set(store: Store, name: string, value: string): string
+export function set<T>(
+  store: Store,
+  name: string,
+  value: T,
+  transform: SetTransform<T>
+): string
+export function set<T>(
+  store: Store,
+  name: string,
+  value: T,
+  transform?: SetTransform<T>
+) {
+  store.setItem(name, transform ? transform(value) : (value as string))
+  return value
+}
+
+export function getFactory(store: Store): (name: string) => string | null
+export function getFactory<T>(
+  store: Store,
+  transform: GetTransform<T>
+): (name: string | null) => T
+export function getFactory<T>(store: Store, transform?: GetTransform<T>) {
+  return (name: string | null) =>
+    transform ? get(store, name!, transform) : get(store, name!)
+}
+
+export function setFactory(
+  store: Store
+): (name: string, value: string) => string
+export function setFactory<T>(
+  store: Store,
+  transform: SetTransform<T>
+): (name: string, value: T) => string
+export function setFactory<T>(store: Store, transform?: SetTransform<T>) {
+  return (name: string, value: T) =>
+    transform
+      ? set(store, name, transform(value))
+      : set(store, name, value as string)
+}
+
+export function localGetFactory(): (name: string) => string | null
+export function localGetFactory<T>(
+  transform: GetTransform<T>
+): (name: string | null) => T
+export function localGetFactory<T>(transform?: GetTransform<T>) {
+  return getFactory(localStorage, transform!)
+}
+
+export function localSetFactory(): (name: string, value: string) => string
+export function localSetFactory<T>(
+  transform: SetTransform<T>
+): (name: string, value: T) => string
+export function localSetFactory<T>(transform?: SetTransform<T>) {
+  return setFactory(localStorage, transform!)
+}
+export function localDel(key: string) {
+  localStorage.removeItem(key)
+}
+
+export const local = {
+  get: localGetFactory(),
+  set: localSetFactory(),
+  del: localDel
+}

+ 113 - 114
src/views/guide/edit-paths.vue

@@ -1,111 +1,115 @@
 <template>
   <div class="video">
     <div class="overflow">
-      <ui-icon 
-        ctrl 
-        :type="isScenePlayIng ? 'pause' : 'preview'" 
-        :disabled="!paths.length" 
+      <ui-icon
+        ctrl
+        :type="isScenePlayIng ? 'pause' : 'preview'"
+        :disabled="!paths.length"
         @click="play"
       />
-      <ui-button 
-        type="primary" 
-        @click="addPath" 
-        width="200px" 
+      <ui-button
+        type="primary"
+        @click="addPath"
+        width="200px"
         :class="{ disabled: isScenePlayIng }"
       >
-        添加视角
+        {{ $t("sys.guide.addPath") }}
       </ui-button>
     </div>
     <div class="info" v-if="paths.length">
       <div class="meta">
         <div class="length">
-          <span>视频时长</span>{{paths.reduce((t, c) => t + c.time, 0).toFixed(1)}}s
+          <span>{{ $t("sys.guide.length") }}</span
+          >{{ paths.reduce((t, c) => t + c.time, 0).toFixed(1) }}s
         </div>
-        <div 
-          class="fun-ctrl clear" 
-          @click="deleteAll" 
+        <div
+          class="fun-ctrl clear"
+          @click="deleteAll"
           :class="{ disabled: isScenePlayIng }"
         >
           <ui-icon type="del" />
-          <span>清空画面</span>
+          <span>{{ $t("sys.guide.clear") }}</span>
         </div>
       </div>
 
       <div class="photo-list" ref="listVm">
         <template v-for="(path, i) in paths" :key="path.id">
-          <div 
-            class="photo" 
+          <div
+            class="photo"
             :class="{ active: current === path, disabled: isScenePlayIng }"
             @click="changeCurrent(path)"
           >
-            <ui-icon 
-              type="del" 
-              ctrl 
-              @click.stop="deletePath(path)" 
-              :class="{ disabled: isScenePlayIng }" 
+            <ui-icon
+              type="del"
+              ctrl
+              @click.stop="deletePath(path)"
+              :class="{ disabled: isScenePlayIng }"
             />
             <img :src="getResource(getFileUrl(path.cover))" />
           </div>
           <div class="set-phone-attr" v-if="i !== paths.length - 1">
-            <ui-input 
-              type="number" 
-              width="54px" 
+            <ui-input
+              type="number"
+              width="54px"
               height="26px"
-              :modelValue="path.speed" 
+              :modelValue="path.speed"
               @update:modelValue="(val: number) => updatePathInfo(i, { speed: val })"
-              :ctrl="false" 
-              :min="0.1" 
+              :ctrl="false"
+              :min="0.1"
               :max="10"
             >
               <template #icon><span>m/s</span></template>
             </ui-input>
-            <ui-input 
-              type="number" 
-              width="54px" 
-              height="26px" 
-              v-model="path.time" 
+            <ui-input
+              type="number"
+              width="54px"
+              height="26px"
+              v-model="path.time"
               @update:modelValue="(val: number) => updatePathInfo(i, { time: val })"
-              :ctrl="false" 
-              :min="0.1" 
-              :max="20" 
+              :ctrl="false"
+              :min="0.1"
+              :max="20"
               class="time"
             >
               <template #icon><span class="time">s</span></template>
             </ui-input>
           </div>
         </template>
-        
       </div>
     </div>
-    <p class="un-video" v-else>暂无导览</p>
+    <p class="un-video" v-else>{{ $t("sys.guide.un") }}</p>
   </div>
 </template>
 
 <script setup lang="ts">
-import { loadPack, togetherCallback, getFileUrl, asyncTimeout } from '@/utils'
-import { sdk, playSceneGuide, pauseSceneGuide, isScenePlayIng } from '@/sdk'
-import { createGuidePath, isTemploraryID, useAutoSetMode, guides } from '@/store'
-import { Dialog, Message } from 'bill/index'
-import { useViewStack } from '@/hook'
-import { nextTick, ref, toRaw, watchEffect } from 'vue'
-import { showRightPanoStack, showLeftCtrlPanoStack, showLeftPanoStack, showRightCtrlPanoStack, getResource } from '@/env'
+import { loadPack, togetherCallback, getFileUrl, asyncTimeout } from "@/utils";
+import { sdk, playSceneGuide, pauseSceneGuide, isScenePlayIng } from "@/sdk";
+import { createGuidePath, isTemploraryID, useAutoSetMode, guides } from "@/store";
+import { Dialog, Message } from "bill/index";
+import { useViewStack } from "@/hook";
+import { nextTick, ref, toRaw, watchEffect } from "vue";
+import {
+  showRightPanoStack,
+  showLeftCtrlPanoStack,
+  showLeftPanoStack,
+  showRightCtrlPanoStack,
+  getResource,
+} from "@/env";
 
-import type { Guide, GuidePaths, GuidePath } from '@/store'
-import type { CalcPathProps } from '@/sdk'
+import type { Guide, GuidePaths, GuidePath } from "@/store";
+import type { CalcPathProps } from "@/sdk";
+import { ui18n } from "@/lang";
 
-const props = defineProps< { data: Guide }>()
-const paths = ref<GuidePaths>(props.data.paths)
-const current = ref<GuidePath>(paths.value[0])
+const props = defineProps<{ data: Guide }>();
+const paths = ref<GuidePaths>(props.data.paths);
+const current = ref<GuidePath>(paths.value[0]);
 
 const updatePathInfo = (index: number, calcInfo: CalcPathProps[1]) => {
-  const info = sdk.calcPathInfo(
-    paths.value.slice(index, index + 2) as any,
-    calcInfo
-  )
-  Object.assign(paths.value[index], info)
-}
+  const info = sdk.calcPathInfo(paths.value.slice(index, index + 2) as any, calcInfo);
+  Object.assign(paths.value[index], info);
+};
 
-useViewStack(() => 
+useViewStack(() =>
   togetherCallback([
     showRightPanoStack.push(ref(false)),
     showLeftCtrlPanoStack.push(ref(false)),
@@ -114,87 +118,85 @@ useViewStack(() =>
   ])
 );
 
-
 useAutoSetMode(paths, {
-  save() {
-  }
-})
+  save() {},
+});
 
 const addPath = () => {
   loadPack(async () => {
-    const dataURL = await sdk.screenshot(260, 160)
-    const res = await fetch(dataURL)
-    const blob = await res.blob()
+    const dataURL = await sdk.screenshot(260, 160);
+    const res = await fetch(dataURL);
+    const blob = await res.blob();
 
-    const pose = sdk.getPose()
-    const index = paths.value.indexOf(current.value) + 1
-    const path: GuidePath = createGuidePath({ 
-      ...pose, 
-      cover: { url: dataURL, blob } 
-    })
-    paths.value.splice(index, 0, path)
-    current.value = path
+    const pose = sdk.getPose();
+    const index = paths.value.indexOf(current.value) + 1;
+    const path: GuidePath = createGuidePath({
+      ...pose,
+      cover: { url: dataURL, blob },
+    });
+    paths.value.splice(index, 0, path);
+    current.value = path;
     if (paths.value.length > 1) {
-      const index = paths.value.length - 2
-      updatePathInfo(index, { time: 3 })
+      const index = paths.value.length - 2;
+      updatePathInfo(index, { time: 3 });
     }
-  })
-}
+  });
+};
 
 const deletePath = async (path: GuidePath, fore: boolean = false) => {
-  if (fore || (await Dialog.confirm('确定要删除此画面吗?'))) {
-    const index = paths.value.indexOf(path)
+  if (fore || (await Dialog.confirm(ui18n.t("sys.guide.delConfirm")))) {
+    const index = paths.value.indexOf(path);
     if (~index) {
-      paths.value.splice(index, 1)
+      paths.value.splice(index, 1);
     }
     if (path === current.value) {
-      current.value = paths.value[index + (index === 0 ? 0 : -1)]
+      current.value = paths.value[index + (index === 0 ? 0 : -1)];
     }
   }
-}
+};
 
 const deleteAll = async () => {
-  if (await Dialog.confirm('确定要清空画面吗?')) {
+  if (await Dialog.confirm(ui18n.t("sys.guide.delAllConfirm"))) {
     while (paths.value.length) {
-      deletePath(paths.value[0], true)
+      deletePath(paths.value[0], true);
     }
-    current.value = paths.value[0]
+    current.value = paths.value[0];
   }
-}
+};
 
 const changeCurrent = (path: GuidePath) => {
-  sdk.comeTo({ dur: 300, ...path })
-  current.value = path
-}
+  sdk.comeTo({ dur: 300, ...path });
+  current.value = path;
+};
 
 const play = async () => {
   if (isScenePlayIng.value) {
-    pauseSceneGuide()
+    pauseSceneGuide();
   } else {
-    changeCurrent(paths.value[0])
-    await asyncTimeout(400)
+    changeCurrent(paths.value[0]);
+    await asyncTimeout(400);
     playSceneGuide(toRaw(paths.value), (index) => {
-      console.log('guide', index)
-      current.value = paths.value[index - 1]
-    })
+      console.log("guide", index);
+      current.value = paths.value[index - 1];
+    });
   }
-}
+};
 
-const listVm = ref<HTMLDivElement>()
+const listVm = ref<HTMLDivElement>();
 watchEffect(async () => {
-  const index = paths.value.indexOf(current.value)
+  const index = paths.value.indexOf(current.value);
   if (~index && listVm.value) {
-    await nextTick()
-    const scrollWidth = listVm.value.scrollWidth / paths.value.length
-    const centerWidth = listVm.value.offsetWidth / 2
-    const offsetLeft = scrollWidth * index - centerWidth
+    await nextTick();
+    const scrollWidth = listVm.value.scrollWidth / paths.value.length;
+    const centerWidth = listVm.value.offsetWidth / 2;
+    const offsetLeft = scrollWidth * index - centerWidth;
 
     listVm.value.scroll({
       left: offsetLeft,
       top: 0,
-    })
+    });
   }
-})
+});
 </script>
 
 <style lang="scss" scoped>
@@ -219,11 +221,11 @@ watchEffect(async () => {
 
   .meta {
     font-size: 12px;
-    border-bottom: 1px solid rgba(255,255,255,.6);
+    border-bottom: 1px solid rgba(255, 255, 255, 0.6);
     padding: 10px 20px;
     display: flex;
     justify-content: space-between;
-    
+
     .length span {
       margin-right: 10px;
     }
@@ -238,7 +240,6 @@ watchEffect(async () => {
     }
   }
 
-
   .photo-list {
     padding: 10px 20px 20px;
     overflow-x: auto;
@@ -254,8 +255,8 @@ watchEffect(async () => {
 
       &::before,
       &::after {
-        content: '';
-        color: rgba(255,255,255,.6);
+        content: "";
+        color: rgba(255, 255, 255, 0.6);
         position: absolute;
         top: 50%;
         transform: translateY(-50%);
@@ -274,12 +275,11 @@ watchEffect(async () => {
         border-left: 7px solid currentColor;
       }
     }
-    
+
     .photo {
       cursor: pointer;
       flex: none;
       position: relative;
-      
 
       &.active {
         outline: 2px solid var(--colors-primary-base);
@@ -292,8 +292,8 @@ watchEffect(async () => {
         width: 24px;
         font-size: 12px;
         height: 24px;
-        background-color: rgba(0,0,0,0.6);
-        color: rgba(255,255,255,.6);
+        background-color: rgba(0, 0, 0, 0.6);
+        color: rgba(255, 255, 255, 0.6);
         display: flex;
         align-items: center;
         justify-content: center;
@@ -301,7 +301,6 @@ watchEffect(async () => {
         border-radius: 50%;
       }
 
-
       img {
         width: 230px;
         height: 160px;
@@ -314,7 +313,7 @@ watchEffect(async () => {
   height: 100px;
   line-height: 100px;
   text-align: center;
-  color: rgba(255,255,255,0.6);
+  color: rgba(255, 255, 255, 0.6);
   font-size: 1.2em;
 }
 </style>
@@ -339,4 +338,4 @@ watchEffect(async () => {
     text-align: right;
   }
 }
-</style>
+</style>

+ 25 - 26
src/views/guide/index.vue

@@ -4,15 +4,15 @@
       <template #header>
         <ui-button @click="edit(createGuide(), true)">
           <ui-icon type="add" />
-          新增 
+          {{ $t("sys.add") }}
         </ui-button>
       </template>
     </ui-group>
-    <ui-group title="导览列表">
-      <GuideSign 
-        v-for="guide in guides" 
-        :key="guide.id" 
-        :guide="guide" 
+    <ui-group :title="$t('sys.guide.list')">
+      <GuideSign
+        v-for="guide in guides"
+        :key="guide.id"
+        :guide="guide"
         @play="playSceneGuide(guide.paths)"
         @edit="edit(guide)"
         @delete="deleteGuide(guide)"
@@ -26,31 +26,31 @@
 </template>
 
 <script lang="ts" setup>
-import { RightFillPano } from '@/layout'
-import { guides, Guide, createGuide, enterEdit, sysBus, autoSaveGuides } from '@/store'
-import { ref } from 'vue';
-import GuideSign from './sign.vue'
-import EditPaths from './edit-paths.vue'
-import { useViewStack } from '@/hook'
-import { playSceneGuide } from '@/sdk'
+import { RightFillPano } from "@/layout";
+import { guides, Guide, createGuide, enterEdit, sysBus, autoSaveGuides } from "@/store";
+import { ref } from "vue";
+import GuideSign from "./sign.vue";
+import EditPaths from "./edit-paths.vue";
+import { useViewStack } from "@/hook";
+import { playSceneGuide } from "@/sdk";
 
-const currentGuide = ref<Guide | null>()
-const leaveEdit = () => currentGuide.value = null
+const currentGuide = ref<Guide | null>();
+const leaveEdit = () => (currentGuide.value = null);
 const edit = (guide: Guide, insert = false) => {
-  currentGuide.value = guide
-  enterEdit()
+  currentGuide.value = guide;
+  enterEdit();
   if (insert) {
-    guides.value.push(guide)
+    guides.value.push(guide);
   }
-  sysBus.on('leave', leaveEdit)
-}
+  sysBus.on("leave", leaveEdit);
+};
 
 const deleteGuide = (guide: Guide) => {
-  const index = guides.value.indexOf(guide)
-  guides.value.splice(index, 1)
-}
+  const index = guides.value.indexOf(guide);
+  guides.value.splice(index, 1);
+};
 
-useViewStack(autoSaveGuides)
+useViewStack(autoSaveGuides);
 </script>
 
 <style lang="scss" scoped>
@@ -58,5 +58,4 @@ useViewStack(autoSaveGuides)
   height: auto;
   display: block;
 }
-
-</style>
+</style>

+ 32 - 34
src/views/guide/sign.vue

@@ -3,11 +3,12 @@
     <div class="info">
       <div class="guide-cover">
         <img :src="getResource(getFileUrl(guide.cover))" />
-        <ui-icon 
-          type="preview" 
-          class="icon" 
-          ctrl 
-          @click="emit('play')" v-if="guide.paths.length" 
+        <ui-icon
+          type="preview"
+          class="icon"
+          ctrl
+          @click="emit('play')"
+          v-if="guide.paths.length"
         />
       </div>
       <div>
@@ -15,37 +16,36 @@
       </div>
     </div>
     <div class="actions">
-      <ui-more 
-        :options="menus" 
-        style="margin-left: 20px" 
-        @click="(action: keyof typeof actions) => actions[action]()" 
+      <ui-more
+        :options="menus"
+        style="margin-left: 20px"
+        @click="(action: keyof typeof actions) => actions[action]()"
       />
     </div>
   </ui-group-option>
 </template>
 
 <script setup lang="ts">
-import { Guide } from '@/store'
-import { getFileUrl } from '@/utils'
-import { getResource } from '@/env'
+import { Guide } from "@/store";
+import { getFileUrl } from "@/utils";
+import { getResource } from "@/env";
+import { ui18n } from "@/lang";
 
-
-defineProps<{ guide: Guide }>()
-const emit = defineEmits<{ 
-  (e: 'delete'): void 
-  (e: 'play'): void 
-  (e: 'edit'): void 
-}>()
+defineProps<{ guide: Guide }>();
+const emit = defineEmits<{
+  (e: "delete"): void;
+  (e: "play"): void;
+  (e: "edit"): void;
+}>();
 
 const menus = [
-  { label: '编辑', value: 'edit' },
-  { label: '删除', value: 'delete' },
-]
+  { label: ui18n.t("sys.edit"), value: "edit" },
+  { label: ui18n.t("sys.del"), value: "delete" },
+];
 const actions = {
-  edit: () => emit('edit'),
-  delete: () => emit('delete')
-}
-
+  edit: () => emit("edit"),
+  delete: () => emit("delete"),
+};
 </script>
 
 <style lang="scss" scoped>
@@ -68,10 +68,10 @@ const actions = {
     .guide-cover {
       position: relative;
       &::after {
-        content: '';
+        content: "";
         position: absolute;
         inset: 0;
-        background: rgba(0,0,0,.2)
+        background: rgba(0, 0, 0, 0.2);
       }
 
       .icon {
@@ -89,7 +89,7 @@ const actions = {
         object-fit: cover;
         border-radius: 4px;
         overflow: hidden;
-        background-color: rgba(255,255,255,.6);
+        background-color: rgba(255, 255, 255, 0.6);
         display: block;
       }
     }
@@ -104,11 +104,9 @@ const actions = {
       }
     }
   }
-  
+
   .actions {
     flex: none;
-  }  
+  }
 }
-
-
-</style>
+</style>

+ 76 - 54
src/views/merge/index.vue

@@ -4,21 +4,36 @@
       <template #header>
         <Actions class="edit-header" :items="actionItems" />
       </template>
-      <ui-group-option label="等比缩放">
-        <!-- <template #icon>
-          <a href="">设置比例</a>
-        </template> -->
-        <ui-input type="range" v-model="custom.currentModel.scale" v-bind="modelRange.scaleRange" :ctrl="false" width="100%">
+      <ui-group-option :label="$t('sys.merge.scale')">
+        <ui-input
+          type="range"
+          v-model="custom.currentModel.scale"
+          v-bind="modelRange.scaleRange"
+          :ctrl="false"
+          width="100%"
+        >
           <template #icon>%</template>
         </ui-input>
       </ui-group-option>
-      <ui-group-option label="离地高度">
-        <ui-input type="range" v-model="custom.currentModel.bottom" v-bind="modelRange.bottomRange" :ctrl="false" width="100%">
+      <ui-group-option :label="$t('sys.merge.bottom')">
+        <ui-input
+          type="range"
+          v-model="custom.currentModel.bottom"
+          v-bind="modelRange.bottomRange"
+          :ctrl="false"
+          width="100%"
+        >
           <template #icon>m</template>
         </ui-input>
       </ui-group-option>
-      <ui-group-option label="模型不透明度">
-        <ui-input type="range" v-model="custom.currentModel.opacity" v-bind="modelRange.opacityRange" :ctrl="false" width="100%">
+      <ui-group-option :label="$t('sys.merge.opacity')">
+        <ui-input
+          type="range"
+          v-model="custom.currentModel.opacity"
+          v-bind="modelRange.opacityRange"
+          :ctrl="false"
+          width="100%"
+        >
           <template #icon>%</template>
         </ui-input>
       </ui-group-option>
@@ -26,76 +41,83 @@
         <ui-button>配准</ui-button>
       </ui-group-option> -->
       <ui-group-option>
-        <ui-button @click="reset">恢复默认</ui-button>
+        <ui-button @click="reset">{{ $t("sys.merge.reset") }}</ui-button>
       </ui-group-option>
     </ui-group>
   </RightPano>
 </template>
 
 <script lang="ts" setup>
-import { RightPano } from '@/layout'
-import { autoSaveModels, ModelAttrs } from '@/store'
-import { togetherCallback } from '@/utils'
-import Actions from '@/components/actions/index.vue'
-import { getSceneModel, modelRange } from '@/sdk'
-import { useViewStack } from '@/hook'
-import { showLeftCtrlPanoStack, showLeftPanoStack, custom, modelsChangeStoreStack } from '@/env'
-import { ref, nextTick } from 'vue'
-import { Dialog } from 'bill/expose-common'
+import { RightPano } from "@/layout";
+import { autoSaveModels, ModelAttrs } from "@/store";
+import { togetherCallback } from "@/utils";
+import Actions from "@/components/actions/index.vue";
+import { getSceneModel, modelRange } from "@/sdk";
+import { useViewStack } from "@/hook";
+import {
+  showLeftCtrlPanoStack,
+  showLeftPanoStack,
+  custom,
+  modelsChangeStoreStack,
+} from "@/env";
+import { ref, nextTick } from "vue";
+import { Dialog } from "bill/expose-common";
 
-import type { ActionsProps } from '@/components/actions/index.vue'
+import type { ActionsProps } from "@/components/actions/index.vue";
+import { ui18n } from "@/lang";
 
-const active = ref(true)
+const active = ref(true);
 useViewStack(() => {
-  active.value = true
-  return () => active.value = false
-})
+  active.value = true;
+  return () => (active.value = false);
+});
 const defaultAttrs: ModelAttrs = {
   show: true,
   scale: 100,
   opacity: 100,
-  rotation: {x: 0, y: 0, z: 0},
-  position: {x: 0, y: 0, z: 0},
+  rotation: { x: 0, y: 0, z: 0 },
+  position: { x: 0, y: 0, z: 0 },
   bottom: 0,
-}
+};
 
-const actionItems: ActionsProps['items'] = [
+const actionItems: ActionsProps["items"] = [
   {
-    icon: 'move',
-    text: '移动',
+    icon: "move",
+    text: ui18n.t("sys.merge.move"),
     action: () => {
-      getSceneModel(custom.currentModel)?.enterMoveMode()
+      getSceneModel(custom.currentModel)?.enterMoveMode();
       return () => {
-        console.log(getSceneModel(custom.currentModel), 'leave')
-        getSceneModel(custom.currentModel)?.leaveTransform()
-      }
-    }
+        console.log(getSceneModel(custom.currentModel), "leave");
+        getSceneModel(custom.currentModel)?.leaveTransform();
+      };
+    },
   },
   {
-    icon: 'flip',
-    text: '旋转',
+    icon: "flip",
+    text: ui18n.t("sys.merge.rotate"),
     action: () => {
-      getSceneModel(custom.currentModel)?.enterRotateMode()
-      return () => getSceneModel(custom.currentModel)?.leaveTransform()
-    }
+      getSceneModel(custom.currentModel)?.enterRotateMode();
+      return () => getSceneModel(custom.currentModel)?.leaveTransform();
+    },
   },
-]
+];
 
 const reset = async () => {
-  if (custom.currentModel && await Dialog.confirm('确定恢复默认?此操作无法撤销')) {
-    Object.assign(custom.currentModel, JSON.parse(JSON.stringify(defaultAttrs)))
-    await nextTick()
-    custom.currentModel && (custom.currentModel.bottom = 0)
+  if (custom.currentModel && (await Dialog.confirm(ui18n.t("sys.merge.resetConfirm")))) {
+    Object.assign(custom.currentModel, JSON.parse(JSON.stringify(defaultAttrs)));
+    await nextTick();
+    custom.currentModel && (custom.currentModel.bottom = 0);
   }
-}
-
-useViewStack(() => togetherCallback([
-  // showLeftCtrlPanoStack.push(ref(false)),
-  showLeftPanoStack.push(ref(true)),
-  modelsChangeStoreStack.push(ref(true))
-]))
-useViewStack(autoSaveModels)
+};
 
+useViewStack(() =>
+  togetherCallback([
+    // showLeftCtrlPanoStack.push(ref(false)),
+    showLeftPanoStack.push(ref(true)),
+    modelsChangeStoreStack.push(ref(true)),
+  ])
+);
+useViewStack(autoSaveModels);
 </script>
 
 <style lang="scss">
@@ -109,4 +131,4 @@ useViewStack(autoSaveModels)
     right: 5px;
   }
 }
-</style>
+</style>

+ 128 - 124
src/views/tagging/edit.vue

@@ -1,137 +1,144 @@
 <template>
   <div class="edit-hot-layer">
-      <div class="edit-hot-item">
-        <h3 class="edit-title">
-          标注
-          <ui-icon type="close" ctrl @click.stop="$emit('quit')" class="edit-close" />
-        </h3>
-        <!-- <StylesManage 
+    <div class="edit-hot-item">
+      <h3 class="edit-title">
+        {{ $t("sys.tagging.name") }}
+        <ui-icon type="close" ctrl @click.stop="$emit('quit')" class="edit-close" />
+      </h3>
+      <!-- <StylesManage 
           :styles="styles" 
           :active="(getTaggingStyle(tagging.styleId) as TaggingStyle)" 
           @change="style => tagging.styleId = style.id" 
           @delete="deleteStyle"
           @uploadStyles="uploadStyles" 
         /> -->
-        <ui-input 
-          require 
-          class="input" 
-          width="100%" 
-          placeholder="请输入热点标题" 
-          type="text" 
-          v-model="tagging.title"
-          maxlength="15" 
-        />
-        <ui-input
-          class="input"
-          width="100%"
-          height="158px"
-          placeholder="特征描述:"
-          type="richtext"
-          v-model="tagging.desc"
-          :maxlength="200"
-        />
-        <ui-input 
-          class="input preplace" 
-          width="100%" 
-          placeholder="" 
-          type="text" 
-          v-model="tagging.part"
+      <ui-input
+        require
+        class="input"
+        width="100%"
+        :placeholder="$t('sys.tagging.hot.plc')"
+        type="text"
+        v-model="tagging.title"
+        maxlength="15"
+      />
+      <ui-input
+        class="input"
+        width="100%"
+        height="158px"
+        :placeholder="$t('sys.tagging.hot.desc')"
+        type="richtext"
+        v-model="tagging.desc"
+        :maxlength="200"
+      />
+      <ui-input
+        class="input preplace"
+        width="100%"
+        placeholder=""
+        type="text"
+        v-model="tagging.part"
+      >
+        <template #preIcon
+          ><span>{{ $t("sys.tagging.hot.plc") }}</span></template
         >
-          <template #preIcon><span>遗留部位:</span></template>
-        </ui-input>
-        <ui-input 
-          class="input preplace" 
-          width="100%" 
-          placeholder="" 
-          type="text" 
-          v-model="tagging.method"
+      </ui-input>
+      <ui-input
+        class="input preplace"
+        width="100%"
+        placeholder=""
+        type="text"
+        v-model="tagging.method"
+      >
+        <template #preIcon
+          ><span>{{ $t("sys.tagging.hot.method") }}</span></template
         >
-          <template #preIcon><span>提取方法:</span></template>
-        </ui-input>
-        <ui-input 
-          class="input preplace" 
-          width="100%" 
-          type="text" 
-          placeholder=""
-          v-model="tagging.principal"
+      </ui-input>
+      <ui-input
+        class="input preplace"
+        width="100%"
+        type="text"
+        placeholder=""
+        v-model="tagging.principal"
+      >
+        <template #preIcon
+          ><span>{{ $t("sys.tagging.hot.principal") }}</span></template
         >
-          <template #preIcon><span>提取人:</span></template>
-        </ui-input>
-        <ui-input
-            class="input "
-            type="file"
-            width="100%"
-            height="225px"
-            require 
-            preview
-            placeholder="上传图片"
-            othPlaceholder="支持JPG、PNG图片格式,单张不超过5MB,最多支持上传9张。"
-            accept=".jpg, .png"
-            :disable="true"
-            :multiple="true"
-            :maxSize="5 * 1024 * 1024"
-            :maxLen="9"
-            :modelValue="tagging.images"
-            @update:modelValue="fileChange"
-        >
-            <template v-slot:valuable>
-                <Images :tagging="tagging" :hideInfo="true">
-                  <template v-slot:icons="{ active }">
-                    <span @click="delImageHandler(active)" class="del-file">
-                      <ui-icon type="del" ctrl />
-                    </span>
-                  </template>
-                </Images>
+      </ui-input>
+      <ui-input
+        class="input"
+        type="file"
+        width="100%"
+        height="225px"
+        require
+        preview
+        :placeholder="$t('sys.tagging.hot.upimg')"
+        :othPlaceholder="$t('sys.tagging.hot.plcimg')"
+        accept=".jpg, .png"
+        :disable="true"
+        :multiple="true"
+        :maxSize="5 * 1024 * 1024"
+        :maxLen="9"
+        :modelValue="tagging.images"
+        @update:modelValue="fileChange"
+      >
+        <template v-slot:valuable>
+          <Images :tagging="tagging" :hideInfo="true">
+            <template v-slot:icons="{ active }">
+              <span @click="delImageHandler(active)" class="del-file">
+                <ui-icon type="del" ctrl />
+              </span>
             </template>
-        </ui-input>
-        <div class="edit-hot" >
-          <span @click="submitHandler" class="fun-ctrl">
-            <ui-icon type="edit" />
-            确定
-          </span>
-        </div>
+          </Images>
+        </template>
+      </ui-input>
+      <div class="edit-hot">
+        <span @click="submitHandler" class="fun-ctrl">
+          <ui-icon type="edit" />
+          {{ $t("sys.enter") }}
+        </span>
       </div>
+    </div>
   </div>
 </template>
 
 <script lang="ts" setup>
 // import StylesManage from './styles.vue'
-import Images from './images.vue'
+import Images from "./images.vue";
 // import { LocalTaggingStyle } from './styles.vue'
-import { computed, ref } from 'vue';
-import { Dialog, Message } from 'bill/index';
-import { 
+import { computed, ref } from "vue";
+import { Dialog, Message } from "bill/index";
+import {
   // taggingStyles,
-  Tagging, 
+  Tagging,
   Taggings,
-  // TemploraryID, 
-  // getTaggingStyle, 
+  // TemploraryID,
+  // getTaggingStyle,
   // TaggingStyle,
   // taggings
-} from '@/store'
+} from "@/store";
+import { ui18n } from "@/lang";
 
 export type EditProps = {
   // taggingFiles: WeakMap<Tagging, Array<File>>
   // styleFile: WeakMap<TaggingStyle, File>
-  data: Tagging
-}
+  data: Tagging;
+};
 
-const props = defineProps<EditProps>()
-const emit = defineEmits<{ (e: 'quit'): void, (e: 'save', data: Tagging): void }>()
-const tagging = ref<Tagging>({...props.data, images: [...props.data.images]})
+const props = defineProps<EditProps>();
+const emit = defineEmits<{ (e: "quit"): void; (e: "save", data: Tagging): void }>();
+const tagging = ref<Tagging>({ ...props.data, images: [...props.data.images] });
 
 const submitHandler = () => {
   if (!tagging.value.title.trim()) {
-    Message.error('标注标题必须填写!')
+    Message.error(ui18n.t("sys.tagging.hot.titerr"));
   } else if (!tagging.value.images.length) {
-    Message.error('至少上传一张图片!')
+    Message.error(ui18n.t("sys.tagging.hot.imgerr"));
   } else {
-    emit('save', tagging.value)
+    emit("save", tagging.value);
   }
-}
+};
 
-// const styles = computed(() => 
-//   [...taggingStyles.value].sort((a, b) => 
+// const styles = computed(() =>
+//   [...taggingStyles.value].sort((a, b) =>
 //     a.default ? -1 : b.default ? 1 : a.id === TemploraryID ? -1 : b.id === TemploraryID ? 1 : 0
 //   )
 // )
@@ -173,42 +180,40 @@ const submitHandler = () => {
 //   tagging.value.styleId = addStyles[0].id
 // }
 
-type LocalImageFile = { file: File; preview: string } | Tagging['images'][number]
+type LocalImageFile = { file: File; preview: string } | Tagging["images"][number];
 const fileChange = (file: LocalImageFile | LocalImageFile[]) => {
-  const files = Array.isArray(file) ? file : [file]
+  const files = Array.isArray(file) ? file : [file];
 
-  tagging.value.images = files.map(atom => {
-    if (typeof atom === 'string' || 'blob' in atom) {
-      return atom
+  tagging.value.images = files.map((atom) => {
+    if (typeof atom === "string" || "blob" in atom) {
+      return atom;
     } else {
-      console.log(atom)
+      console.log(atom);
       return {
         blob: atom.file,
-        url: atom.preview
-      }
+        url: atom.preview,
+      };
     }
-  })
-}
+  });
+};
 
-const delImageHandler = async (file: Tagging['images'][number]) => {
-  const index = tagging.value.images.indexOf(file)
-  if (~index && (await Dialog.confirm(`确定要删除此数据吗?`))) {
-    tagging.value.images.splice(index, 1)
+const delImageHandler = async (file: Tagging["images"][number]) => {
+  const index = tagging.value.images.indexOf(file);
+  if (~index && (await Dialog.confirm(ui18n.t("sys.delConfirm")))) {
+    tagging.value.images.splice(index, 1);
   }
-}
-
+};
 </script>
 
 <style lang="scss" scoped>
 .edit-hot-layer {
   position: fixed;
   inset: 0;
-  background: rgba(0,0,0,0.3000);
+  background: rgba(0, 0, 0, 0.3);
   backdrop-filter: blur(4px);
   z-index: 2000;
   padding: 20px;
   overflow-y: auto;
-
 }
 
 .edit-hot-item {
@@ -216,13 +221,12 @@ const delImageHandler = async (file: Tagging['images'][number]) => {
   width: 400px;
   padding: 20px;
   background: rgba(27, 27, 28, 0.8);
-  box-shadow: 0px 0px 10px 0px rgba(0,0,0, 0.3);
+  box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3);
   border-radius: 4px;
 
   .input {
     margin-bottom: 10px;
   }
-
 }
 .edit-close {
   position: absolute;
@@ -238,13 +242,13 @@ const delImageHandler = async (file: Tagging['images'][number]) => {
   position: relative;
 
   &::after {
-    content: '';
+    content: "";
     position: absolute;
     left: -20px;
     right: -20px;
     height: 1px;
     bottom: 0;
-    background-color: rgba(255, 255, 255, 0.16);;
+    background-color: rgba(255, 255, 255, 0.16);
   }
 }
 
@@ -260,13 +264,13 @@ const delImageHandler = async (file: Tagging['images'][number]) => {
 }
 </style>
 <style>
-.edit-hot-item .preplace input{
+.edit-hot-item .preplace input {
   padding-left: 76px !important;
 }
 
 .edit-hot-item .preplace .pre-icon {
-  color: rgba(255,255,255,0.6000);
+  color: rgba(255, 255, 255, 0.6);
   width: 70px;
   text-align: right;
 }
-</style>
+</style>

+ 117 - 115
src/views/tagging/index.vue

@@ -2,168 +2,170 @@
   <RightFillPano>
     <ui-group borderBottom>
       <template #header>
-        <ui-button @click="editTagging = createTagging()">
+        <ui-button @click="insertTagging">
           <ui-icon type="add" />
-          新增
+          {{ $t("sys.add") }}
         </ui-button>
       </template>
     </ui-group>
-    <ui-group title="标注">
+    <ui-group :title="$t('sys.tagging.name')">
       <template #icon>
-        <ui-icon 
-          ctrl
-          :type="custom.showTaggings ? 'eye-s' : 'eye-n'" 
-          @click="custom.showTaggings = !custom.showTaggings" 
-        />
+        <ui-icon ctrl :type="custom.showTaggings ? 'eye-s' : 'eye-n'"
+          @click="custom.showTaggings = !custom.showTaggings" />
       </template>
       <ui-group-option>
-        <ui-input type="text" width="100%" placeholder="搜索" v-model="keyword">
+        <ui-input type="text" width="100%" :placeholder="$t('sys.search')" v-model="keyword">
           <template #preIcon>
             <ui-icon type="search" />
           </template>
         </ui-input>
       </ui-group-option>
-      <TagingSign 
-        v-for="tagging in filterTaggings" 
-        :key="tagging.id" 
-        :tagging="tagging" 
-        :selected="selectTagging === tagging"
-        @edit="editTagging = tagging"
-        @delete="deleteTagging(tagging)"
-        @select="selectTagging = tagging"
-        @flyPositions="flyTaggingPositions(tagging)"
-      />
+      <TagingSign v-for="tagging in filterTaggings" :key="tagging.id" :tagging="tagging"
+        :selected="selectTagging === tagging" @edit="editTagging = tagging" @delete="deleteTagging(tagging)"
+        @select="selectTagging = tagging" @flyPositions="flyTaggingPositions(tagging)" />
     </ui-group>
   </RightFillPano>
 
-  <Edit 
-    v-if="editTagging" 
-    :data="editTagging" 
-    @quit="editTagging = null" 
-    @save="saveHandler"
-  />
+  <Edit v-if="editTagging" :data="editTagging" @quit="editTagging = null" @save="saveHandler" />
 </template>
 
 <script lang="ts" setup>
-import Edit from './edit.vue'
-import TagingSign from './sign.vue'
-import { Message } from 'bill/index'
-import { RightFillPano } from '@/layout'
-import { asyncTimeout, togetherCallback } from '@/utils'
-import { useViewStack } from '@/hook'
-import { computed, nextTick, ref, watch } from 'vue';
-import { sdk } from '@/sdk'
-import { 
-  taggings, 
-  isTemploraryID, 
-  Tagging, 
-  autoSaveTaggings, 
+import Edit from "./edit.vue";
+import TagingSign from "./sign.vue";
+import { Message } from "bill/index";
+import { RightFillPano } from "@/layout";
+import { asyncTimeout, togetherCallback } from "@/utils";
+import { useViewStack } from "@/hook";
+import { computed, nextTick, ref, toRaw, watch, watchEffect } from "vue";
+import { sdk } from "@/sdk";
+import {
+  taggings,
+  isTemploraryID,
+  Tagging,
+  autoSaveTaggings,
   createTagging,
-  enterEdit, 
+  enterEdit,
   Model,
   getModel,
   getModelShowVariable,
   getTaggingPositions,
   taggingPositions,
   createTaggingPosition,
-models
-} from '@/store'
-import { 
-  custom, 
+  models,
+} from "@/store";
+import {
+  custom,
   showTaggingPositionsStack,
-  showLeftCtrlPanoStack, 
+  showLeftCtrlPanoStack,
   showLeftPanoStack,
   currentModelStack,
   showRightCtrlPanoStack,
-  showRightPanoStack
-} from '@/env'
-
-const keyword = ref('')
-const filterTaggings = computed(() => taggings.value.filter(tagging => tagging.title.includes(keyword.value)))
+  showRightPanoStack,
+} from "@/env";
+import { ui18n } from "@/lang";
+
+const keyword = ref("");
+const filterTaggings = computed(() =>
+  taggings.value.filter((tagging) => tagging.title.includes(keyword.value))
+);
+
+const insertTagging = () => {
+  editTagging.value = createTagging();
+  insertIng = true;
+};
+
+const editTagging = ref<Tagging | null>(null);
+let insertIng = false;
+watchEffect(() => {
+  if (!editTagging.value) {
+    insertIng = false;
+  }
+});
 
-const editTagging = ref<Tagging | null>(null)
 const saveHandler = (tagging: Tagging) => {
   if (!editTagging.value) return;
-  if (isTemploraryID(editTagging.value.id)) {
-    taggings.value.push(tagging)
+  if (insertIng) {
+    taggings.value.push(tagging);
   } else {
-    Object.assign(editTagging.value, tagging)
+    Object.assign(editTagging.value, tagging);
   }
-  editTagging.value = null
-}
+  editTagging.value = null;
+};
 
 const deleteTagging = (tagging: Tagging) => {
-  const index = taggings.value.indexOf(tagging)
-  const positions = getTaggingPositions(tagging)
-  taggingPositions.value = taggingPositions.value.filter(position => !positions.includes(position))
-  taggings.value.splice(index, 1)
-}
-
-let stopFlyTaggingPositions: () => void
+  const index = taggings.value.indexOf(tagging);
+  const positions = getTaggingPositions(tagging);
+  taggingPositions.value = taggingPositions.value.filter(
+    (position) => !positions.includes(position)
+  );
+  taggings.value.splice(index, 1);
+};
+
+let stopFlyTaggingPositions: () => void;
 const flyTaggingPositions = (tagging: Tagging) => {
-  const positions = getTaggingPositions(tagging)
-  stopFlyTaggingPositions && stopFlyTaggingPositions()
+  const positions = getTaggingPositions(tagging);
+  stopFlyTaggingPositions && stopFlyTaggingPositions();
 
-  let isStop = false
+  let isStop = false;
 
   const flyIndex = (i: number) => {
     if (isStop || i >= positions.length) {
       return;
     }
-    const position = positions[i]
-    const model = getModel(position.modelId)
+    const position = positions[i];
+    const model = getModel(position.modelId);
     if (!model || !getModelShowVariable(model).value) {
-      flyIndex(i + 1)
+      flyIndex(i + 1);
       return;
     }
 
-    
-    const pop = showTaggingPositionsStack.push(ref(new WeakSet([position])))
-    sdk.comeTo({ 
-      position: position.localPos, 
+    const pop = showTaggingPositionsStack.push(ref(new WeakSet([position])));
+    sdk.comeTo({
+      position: position.localPos,
       modelId: position.modelId,
       dur: 300,
-      distance: 3
-    })
-    
-    console.log('改变了', custom.showTaggingPositions.has(position))
+      distance: 3,
+    });
+
+    console.log("改变了", custom.showTaggingPositions.has(position));
     setTimeout(() => {
-      pop()
-      flyIndex(i + 1)
-    }, 2000)
-  }
-  flyIndex(0)
-  stopFlyTaggingPositions = () => isStop = true
-}
+      pop();
+      flyIndex(i + 1);
+    }, 2000);
+  };
+  flyIndex(0);
+  stopFlyTaggingPositions = () => (isStop = true);
+};
 
 const stopFlyKeyupHandler = (ev: KeyboardEvent) => {
-  ev.code === 'Escape' && stopFlyTaggingPositions && stopFlyTaggingPositions()
-}
+  ev.code === "Escape" && stopFlyTaggingPositions && stopFlyTaggingPositions();
+};
 useViewStack(() => {
-  document.documentElement.addEventListener('keyup', stopFlyKeyupHandler, false)
-  return () => document.documentElement.removeEventListener('keydown', stopFlyKeyupHandler, false)
-})
+  document.documentElement.addEventListener("keyup", stopFlyKeyupHandler, false);
+  return () =>
+    document.documentElement.removeEventListener("keydown", stopFlyKeyupHandler, false);
+});
 
-const selectTagging = ref<Tagging | null>(null)
+const selectTagging = ref<Tagging | null>(null);
 watch(selectTagging, (a, b, onCleanup) => {
   if (selectTagging.value) {
-    const leave = () => selectTagging.value = null
+    const leave = () => (selectTagging.value = null);
 
     const pop = togetherCallback([
-      showLeftCtrlPanoStack.push(ref(true)), 
+      showLeftCtrlPanoStack.push(ref(true)),
       showLeftPanoStack.push(ref(true)),
       showRightCtrlPanoStack.push(ref(false)),
-      showRightPanoStack.push(ref(false))
-    ])
+      showRightPanoStack.push(ref(false)),
+    ]);
 
     const clickHandler = async (ev: MouseEvent) => {
-      await nextTick()
-      await asyncTimeout()
+      await nextTick();
+      await asyncTimeout();
       const position = sdk.getPositionByScreen({
         x: ev.clientX,
-        y: ev.clientY
-      })
-      console.error(position)
+        y: ev.clientY,
+      });
+      console.error(position);
 
       // const positions = models.value
       //   .filter(model => {
@@ -179,29 +181,29 @@ watch(selectTagging, (a, b, onCleanup) => {
       //   .filter(pos => pos)
 
       if (!position) {
-        Message.error('当前位置无法添加')
+        Message.error(ui18n.t("sys.tagging.errpos"));
       } else if (selectTagging.value) {
         const storePosition = createTaggingPosition({
           ...position,
-          taggingId: selectTagging.value.id
-        })
-        taggingPositions.value.push(storePosition)
-        leave()
+          taggingId: selectTagging.value.id,
+        });
+        taggingPositions.value.push(storePosition);
+        leave();
       }
-    }
-    const keyupHandler = (ev: KeyboardEvent) => ev.code === 'Escape' && leave()
+    };
+    const keyupHandler = (ev: KeyboardEvent) => ev.code === "Escape" && leave();
 
-    document.documentElement.addEventListener('keyup', keyupHandler, false);
-    sdk.layout.addEventListener('click', clickHandler, false)
+    document.documentElement.addEventListener("keyup", keyupHandler, false);
+    sdk.layout.addEventListener("click", clickHandler, false);
 
-    enterEdit(leave)
+    enterEdit(leave);
     onCleanup(() => {
-      document.documentElement.removeEventListener('keyup', keyupHandler, false);
-      sdk.layout.removeEventListener('click', clickHandler, false);
-      pop()
-    })
+      document.documentElement.removeEventListener("keyup", keyupHandler, false);
+      sdk.layout.removeEventListener("click", clickHandler, false);
+      pop();
+    });
   }
-})
+});
 
-useViewStack(autoSaveTaggings)
-</script>
+useViewStack(autoSaveTaggings);
+</script>

+ 37 - 45
src/views/tagging/sign.vue

@@ -1,67 +1,59 @@
 <template>
-  <ui-group-option class="sign-tagging" :class="{active: selected}" @click="emit('select')">
+  <ui-group-option class="sign-tagging" :class="{ active: selected }" @click="emit('select')">
     <div class="info">
-      <img :src="getResource(getFileUrl(tagging.images.length ? tagging.images[0] : style.icon))" v-if="style">
+      <img :src="getResource(getFileUrl(tagging.images.length ? tagging.images[0] : style.icon))
+    " v-if="style" />
       <div>
         <p>{{ tagging.title }}</p>
-        <a>放置:{{ positions.length }}</a>
+        <a>{{ $t("sys.tagging.fz") }}{{ positions.length }}</a>
       </div>
     </div>
     <div class="actions" @click.stop>
-      <ui-icon 
-        :class="{disabled: disabledFly}"
-        type="pin" 
-        ctrl  
-        @click.stop="$emit('flyPositions')"
-      />
-      <ui-more 
-        :options="menus" 
-        style="margin-left: 20px" 
-        @click="(action: keyof typeof actions) => actions[action]()" 
-      />
+      <ui-icon :class="{ disabled: disabledFly }" type="pin" ctrl @click.stop="$emit('flyPositions')" />
+      <ui-more :options="menus" style="margin-left: 20px"
+        @click="(action: keyof typeof actions) => actions[action]()" />
     </div>
   </ui-group-option>
 </template>
 
 <script setup lang="ts">
-import { getFileUrl } from '@/utils'
-import { computed } from 'vue';
-import { getResource } from '@/env'
-import { 
-  getTaggingStyle, 
-  getTaggingPositions, 
+import { getFileUrl } from "@/utils";
+import { computed } from "vue";
+import { getResource } from "@/env";
+import {
+  getTaggingStyle,
+  getTaggingPositions,
   getModel,
-  getModelShowVariable
-} from '@/store'
+  getModelShowVariable,
+} from "@/store";
 
-import type { Tagging } from '@/store'
+import type { Tagging } from "@/store";
+import { ui18n } from "@/lang";
 
-const props = defineProps<{ tagging: Tagging, selected?: boolean }>()
-const style = computed(() => getTaggingStyle(props.tagging.styleId))
-const positions = computed(() => getTaggingPositions(props.tagging))
-const disabledFly = computed(() => 
+const props = defineProps<{ tagging: Tagging; selected?: boolean }>();
+const style = computed(() => getTaggingStyle(props.tagging.styleId));
+const positions = computed(() => getTaggingPositions(props.tagging));
+const disabledFly = computed(() =>
   positions.value
-    .map(position => getModel(position.modelId))
-    .every(model => !model || !getModelShowVariable(model).value)
-)
+    .map((position) => getModel(position.modelId))
+    .every((model) => !model || !getModelShowVariable(model).value)
+);
 
-const emit = defineEmits<{ 
-  (e: 'delete'): void 
-  (e: 'edit'): void
-  (e: 'select'): void
-  (e: 'flyPositions'): void
-}>()
+const emit = defineEmits<{
+  (e: "delete"): void;
+  (e: "edit"): void;
+  (e: "select"): void;
+  (e: "flyPositions"): void;
+}>();
 
 const menus = [
-  { label: '编辑', value: 'edit' },
-  { label: '删除', value: 'delete' },
-]
+  { label: ui18n.t("sys.edit"), value: "edit" },
+  { label: ui18n.t("sys.del"), value: "delete" },
+];
 const actions = {
-  edit: () => emit('edit'),
-  delete: () => emit('delete')
-}
-
-
+  edit: () => emit("edit"),
+  delete: () => emit("delete"),
+};
 </script>
 
-<style lang="scss" scoped src="./style.scss"></style>
+<style lang="scss" scoped src="./style.scss"></style>

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

@@ -30,4 +30,15 @@ type PartialProps<T, U extends keyof T = keyof T> = {
     [P in keyof Omit<T, U>]: T[P];
 } & {
   [P in U]?: T[P];
-}
+}
+
+declare type I18nGlobalTranslation = {
+  (key: string): string
+  (key: string, locale: string): string
+  (key: string, locale: string, list: unknown[]): string
+  (key: string, locale: string, named: Record<string, unknown>): string
+  (key: string, list: unknown[]): string
+  (key: string, named: Record<string, unknown>): string
+}
+
+declare const $t: I18nGlobalTranslation

+ 1 - 1
tsconfig.json

@@ -4,7 +4,7 @@
     "useDefineForClassFields": true,
     "module": "ESNext",
     "moduleResolution": "Node",
-    "strict": true,
+    "strict": false,
     "jsx": "preserve",
     "sourceMap": true,
     "allowJs": true,

+ 54 - 32
vite.config.ts

@@ -1,6 +1,7 @@
 import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
 import fs from 'fs'
+import { createServer as createLangServer } from './scripts/lang.js'
 
 import { resolve } from 'path'
 
@@ -8,36 +9,57 @@ const outDir = resolve(process.env.PWD, process.argv[process.argv.length - 1])
 
 
 // https://vitejs.dev/config/
-export default defineConfig({
-  build: {
-    outDir: fs.existsSync(outDir) ? outDir : './dist'
-  },
-  plugins: [vue()],
-  base: './',
-  assetsInclude: ['public/**/*'],
-  resolve: {
-    extensions: ['.js', '.ts', '.json', '.vue'],
-    alias: [
-      {
-        find: '@',
-        replacement: resolve(__dirname, './src')
+export default async ({ mode }) => {
+  const langProt = 9091
+  const input: { [key in string]: string } = {}
+  if (process.argv.includes('lang')) {
+    await createLangServer(langProt)
+    input.lang = resolve(__dirname, 'lang.html')
+  } else {
+    input.main = resolve(__dirname, 'index.html')
+  }
+  const proxy = {
+    '/dev': {
+      target: `http://localhost:${langProt}`,
+      changeOrigin: true,
+      rewrite: path => path.replace(/^\/dev/, '/dev')
+    },
+    '/api': {
+      target: 'http://192.168.0.152:8088/',
+      changeOrigin: true,
+      rewrite: path => path.replace(/^\/api/, '')
+    }
+  }
+
+  console.log(input)
+  return defineConfig({
+    build: {
+      rollupOptions: {
+        input
       },
-      {
-        find: 'bill',
-        replacement: resolve(__dirname, './src/components/bill-ui')
-      }
-    ]
-  },
-  // server: {
-  //   host: '0.0.0.0',
-  //   port: 5173,
-  //   open: true,
-  //   proxy: {
-  //     '/api': {
-  //       target: 'http://192.168.0.152:8088/',
-  //       changeOrigin: true,
-  //       rewrite: path => path.replace(/^\/api/, '')
-  //     }
-  //   }
-  // }
-})
+      outDir: fs.existsSync(outDir) ? outDir : './dist'
+    },
+    plugins: [vue()],
+    base: './',
+    assetsInclude: ['public/**/*'],
+    resolve: {
+      extensions: ['.js', '.ts', '.json', '.vue'],
+      alias: [
+        {
+          find: '@',
+          replacement: resolve(__dirname, './src')
+        },
+        {
+          find: 'bill',
+          replacement: resolve(__dirname, './src/components/bill-ui')
+        }
+      ]
+    },
+    server: {
+      host: '0.0.0.0',
+      port: 5173,
+      open: true,
+      proxy: proxy
+    }
+  })
+}