bill 1 rok pred
rodič
commit
efa7afd105

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 3 - 0
.vscode/extensions.json

@@ -0,0 +1,3 @@
+{
+  "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"],
+}

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Vite + Vue + TS</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 32 - 0
package.json

@@ -0,0 +1,32 @@
+{
+  "name": "4d-map",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@element-plus/icons-vue": "^2.3.1",
+    "@types/node": "^20.12.2",
+    "element-plus": "^2.6.3",
+    "gl-matrix": "^3.4.3",
+    "js-base64": "^3.7.7",
+    "mitt": "^3.0.1",
+    "ol": "^9.1.0",
+    "proj4": "^2.11.0",
+    "vue": "^3.4.21",
+    "vue-router": "^4.3.0",
+    "xlsx": "^0.18.5"
+  },
+  "devDependencies": {
+    "@types/proj4": "^2.5.5",
+    "@vitejs/plugin-vue": "^5.0.4",
+    "sass": "^1.72.0",
+    "typescript": "^5.2.2",
+    "vite": "^5.2.0",
+    "vue-tsc": "^2.0.6"
+  }
+}

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1248 - 0
pnpm-lock.yaml


BIN
public/criminalBanner.png


BIN
public/map/point_a.png


BIN
public/template.xls


+ 41 - 0
src/App.vue

@@ -0,0 +1,41 @@
+<template>
+  <el-config-provider :locale="zhCn" :message="{ max: 3 }">
+    <RouterView v-slot="{ Component }">
+      <KeepAlive>
+        <component :is="Component" />
+      </KeepAlive>
+    </RouterView>
+  </el-config-provider>
+</template>
+
+<script lang="ts" setup>
+import { ElLoading } from "element-plus";
+import zhCn from "element-plus/locale/zh-cn.mjs";
+import { lifeHook } from "./request/state";
+
+let loading: ReturnType<typeof ElLoading.service> | null = null;
+let timeout: ReturnType<typeof setTimeout>;
+
+lifeHook.push({
+  start: () => {
+    clearTimeout(timeout);
+    if (!loading) {
+      loading = ElLoading.service({
+        lock: true,
+        fullscreen: true,
+        text: "加载中",
+        background: "rgba(0, 0, 0, 0.7)",
+      });
+    }
+  },
+  end: () => {
+    if (loading) {
+      clearTimeout(timeout);
+      timeout = setTimeout(() => {
+        loading!.close();
+        loading = null;
+      }, 16);
+    }
+  },
+});
+</script>

+ 1 - 0
src/assets/vue.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 38 - 0
src/components/HelloWorld.vue

@@ -0,0 +1,38 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+
+defineProps<{ msg: string }>()
+
+const count = ref(0)
+</script>
+
+<template>
+  <h1>{{ msg }}</h1>
+
+  <div class="card">
+    <button type="button" @click="count++">count is {{ count }}</button>
+    <p>
+      Edit
+      <code>components/HelloWorld.vue</code> to test HMR
+    </p>
+  </div>
+
+  <p>
+    Check out
+    <a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
+      >create-vue</a
+    >, the official Vue + Vite starter
+  </p>
+  <p>
+    Install
+    <a href="https://github.com/vuejs/language-tools" target="_blank">Volar</a>
+    in your IDE for a better DX
+  </p>
+  <p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
+</template>
+
+<style scoped>
+.read-the-docs {
+  color: #888;
+}
+</style>

+ 40 - 0
src/components/single-input.vue

@@ -0,0 +1,40 @@
+<template>
+  <el-dialog
+    :model-value="visible"
+    @update:model-value="(val) => emit('update:visible', val)"
+    :title="title"
+    width="500"
+  >
+    <el-input v-model="ivalue" />
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="emit('update:visible', false)">取消</el-button>
+        <el-button type="primary" @click="submit"> 确定 </el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, watchEffect } from "vue";
+
+const props = defineProps<{
+  visible: boolean;
+  value: string;
+  title: string;
+  updateValue: (value: string) => void;
+}>();
+const emit = defineEmits<{
+  (e: "update:visible", visible: boolean): void;
+}>();
+
+const ivalue = ref(props.value);
+watchEffect(() => {
+  ivalue.value = props.value;
+});
+
+const submit = async () => {
+  await props.updateValue(ivalue.value);
+  emit("update:visible", false);
+};
+</script>

+ 11 - 0
src/main.ts

@@ -0,0 +1,11 @@
+import { createApp } from "vue";
+import ElementPlus from "element-plus";
+import "element-plus/dist/index.css";
+import { router } from "./router";
+import App from "./App.vue";
+import "./style.scss";
+
+const app = createApp(App);
+app.use(ElementPlus);
+app.use(router);
+app.mount("#app");

+ 13 - 0
src/request/URL.ts

@@ -0,0 +1,13 @@
+export const login = `/relics/fdLogin`;
+export const logout = `/relics/fdLogout`;
+export const getUserInfo = `/relics/getUserInfo`;
+export const getRelicsPage = `/relics/relicsInfo/page`;
+export const updateRelicsName = `/relics/relicsInfo/update`;
+export const getRelicsInfo = `/relics/relicsInfo/info/:relicsId`;
+export const delRelics = `/relics/relicsInfo/del`;
+export const addRelics = `/relics/relicsInfo/add`;
+export const getRelicsScenes = `/relics/relics-scene/getAllList/:relicsId`;
+export const addRelicsScene = `/relics/relics-scene/add/:relicsId`;
+export const delRelicsScene = `/relics/relics-scene/del`;
+export const updateRelicsScenePosName = `/relics/relics-scene/editPosName`;
+export const getRelicsScenePosInfo = `/relics/relics-scene-pos/get/:posId`;

+ 155 - 0
src/request/index.ts

@@ -0,0 +1,155 @@
+import { gendUrl } from "@/util";
+import * as URL from "./URL";
+import {
+  basePath,
+  errorHook,
+  gHeaders,
+  gParams,
+  gPaths,
+  lifeHook,
+  Param,
+} from "./state";
+import { ElMessage } from "element-plus";
+import {
+  Relics,
+  RelicsScene,
+  RelicsScenePoint,
+  ResPage,
+  UserInfo,
+} from "./type";
+
+type Other = { params?: Param; paths?: Param };
+export const sendFetch = <T>(
+  url: string,
+  init: RequestInit,
+  other?: Other
+): Promise<T> => {
+  const headers = init.headers as Record<string, string>;
+  let sendUrl = url;
+
+  if (other) {
+    if (other.paths) {
+      sendUrl = gendUrl(url, { ...gPaths, ...other.paths });
+    }
+
+    if (other.params) {
+      sendUrl =
+        sendUrl + "?" + new URLSearchParams({ ...gParams, ...other.params });
+    }
+  }
+  lifeHook.forEach(({ start }) => start());
+
+  return fetch(basePath + sendUrl, {
+    ...init,
+    headers: headers
+      ? {
+          ...headers,
+          ...gHeaders,
+        }
+      : gHeaders,
+  })
+    .then((res) => {
+      lifeHook.forEach(({ end }) => end());
+      if (res.status === 200) {
+        return res.json();
+      } else {
+        ElMessage.error("请求错误");
+        throw "请求错误";
+      }
+    })
+    .then((data) => {
+      if (data.code !== 0) {
+        ElMessage.error(data.message);
+        errorHook.map((err) => {
+          err(data.code, data.msg);
+        });
+        throw data.message;
+      } else {
+        return data.data;
+      }
+    });
+};
+
+export type LoginProps = {
+  password: string;
+  phoneNum: string;
+};
+export const loginFetch = (props: LoginProps) =>
+  sendFetch<{ user: UserInfo; token: string }>(URL.login, {
+    method: "post",
+    body: JSON.stringify(props),
+  });
+
+export const userInfoFetch = () =>
+  sendFetch<UserInfo>(URL.getUserInfo, { method: "post" });
+
+export type RelicsPageProps = {
+  name: string;
+  pageNum: number;
+  pageSize: number;
+};
+export const relicsPageFetch = (body: RelicsPageProps) =>
+  sendFetch<ResPage<Relics>>(URL.getRelicsPage, {
+    method: "post",
+    body: JSON.stringify(body),
+  });
+
+export const relicsInfoFetch = (relicsId: number) =>
+  sendFetch<Relics>(
+    URL.getRelicsInfo,
+    {
+      method: "post",
+      body: JSON.stringify({}),
+    },
+    { paths: { relicsId } }
+  );
+
+export const addRelicsFetch = (name: string) =>
+  sendFetch(URL.addRelics, { method: "post", body: JSON.stringify({ name }) });
+
+export const delRelicsFetch = (relicsId: number) =>
+  sendFetch(URL.delRelics, {
+    method: "post",
+    body: JSON.stringify({ relicsId }),
+  });
+
+export const updateRelicsFetch = (relics: Relics) =>
+  sendFetch(URL.updateRelicsName, {
+    method: "post",
+    body: JSON.stringify(relics),
+  });
+
+export const relicsScenesFetch = (relicsId: number) =>
+  sendFetch<RelicsScene[]>(
+    URL.getRelicsScenes,
+    { method: "post", body: JSON.stringify({}) },
+    { paths: { relicsId } }
+  );
+
+export const addRelicsSceneFetch = (relicsId: number, sceneCode: string) =>
+  sendFetch(
+    URL.addRelicsScene,
+    { method: "post", body: JSON.stringify({ sceneCode }) },
+    { paths: { relicsId } }
+  );
+export const delRelicsSceneFetch = (relicsId: number, sceneId: number) =>
+  sendFetch(
+    URL.delRelicsScene,
+    { method: "post", body: JSON.stringify({ id: sceneId }) },
+    { paths: { relicsId } }
+  );
+export const updateRelicsScenePosNameFetch = (posId: number, name: string) =>
+  sendFetch(URL.updateRelicsScenePosName, {
+    method: "post",
+    body: JSON.stringify({ id: posId, name }),
+  });
+
+export const relicsScenePosInfoFetch = (posId: number) =>
+  sendFetch<RelicsScenePoint>(
+    URL.getRelicsScenePosInfo,
+    {
+      method: "post",
+      body: JSON.stringify({}),
+    },
+    { paths: { posId } }
+  );

+ 11 - 0
src/request/state.ts

@@ -0,0 +1,11 @@
+export type Param = { [key in string]: any };
+
+export const gHeaders: Param = {
+  ["Content-Type"]: "application/json",
+};
+export const gPaths: Param = {};
+export const gParams: Param = {};
+
+export const errorHook: ((code: number, msg: string) => void)[] = [];
+export const lifeHook: { start: () => void; end: () => void }[] = [];
+export const basePath = "/api";

+ 34 - 0
src/request/type.ts

@@ -0,0 +1,34 @@
+export type UserInfo = {
+  head: string;
+  nickName: string;
+  userName: string;
+};
+
+export type Relics = {
+  relicsId: number;
+  name: string;
+};
+
+export type ResPage<T> = {
+  total: number;
+  records: T[];
+};
+
+export type RelicsScenePoint = {
+  tbStatus: number;
+  createTime: string;
+  updateTime: string;
+  id: number;
+  uuid: number;
+  name: string;
+  pos: number[];
+  sceneCode: string;
+};
+
+export type RelicsScene = {
+  id: number;
+  sceneCode: string;
+  sceneName: string;
+  title: string;
+  scenePos: RelicsScenePoint[];
+};

+ 57 - 0
src/router.ts

@@ -0,0 +1,57 @@
+import { RouteRecordRaw, createRouter, createWebHashHistory } from "vue-router";
+
+const history = createWebHashHistory();
+const routes: RouteRecordRaw[] = [
+  {
+    path: "/login",
+    name: "login",
+    meta: { title: "登录" },
+    component: () => import("@/view/login.vue"),
+  },
+  {
+    path: "/",
+    name: "main-layout",
+    component: () => import("@/view/layout/nav.vue"),
+    children: [
+      {
+        path: "relics",
+        name: "relics",
+        meta: { title: "文物管理" },
+        component: () => import("@/view/relics.vue"),
+      },
+      {
+        path: "relics/:relicsId",
+        children: [
+          {
+            path: "",
+            name: "map",
+            meta: { title: "文物" },
+            component: () => import("@/view/map/map.vue"),
+          },
+          {
+            path: "pano/:pid",
+            name: "pano",
+            meta: { title: "点位" },
+            component: () => import("@/view/pano/pano.vue"),
+          },
+        ],
+      },
+    ],
+  },
+];
+
+export const router = createRouter({ history, routes });
+export const setDocTitle = (title: string) => {
+  document.title = title + "-不移动动文物管理平台";
+};
+
+router.beforeEach((to, _, next) => {
+  if (!to.name || to.name === "main-layout") {
+    router.replace({ name: "relics" });
+    return;
+  }
+  if (to.meta?.title) {
+    setDocTitle(to.meta.title as string);
+  }
+  next();
+});

+ 75 - 0
src/store/scene.ts

@@ -0,0 +1,75 @@
+import {
+  addRelicsSceneFetch,
+  delRelicsSceneFetch,
+  relicsInfoFetch,
+  relicsScenesFetch,
+  updateRelicsFetch,
+  updateRelicsScenePosNameFetch,
+} from "@/request";
+import { computed, ref } from "vue";
+import { Relics, RelicsScene, RelicsScenePoint } from "@/request/type";
+
+export type { RelicsScene, RelicsScenePoint };
+
+export const relics = ref<Relics>();
+export const scenes = ref<RelicsScene[]>([]);
+
+export const scenePoints = computed(() =>
+  scenes.value.reduce((t, scene) => {
+    t.push(...scene.scenePos);
+    return t;
+  }, [] as RelicsScenePoint[])
+);
+
+const fileNames = new Array(6).fill(0);
+export const getPointPano = (sceneCode: string, pid: number) =>
+  fileNames.map(
+    (_, i) =>
+      `https://4dkk.4dage.com/scene_view_data/${sceneCode}/images/tiles/4k/${pid}_skybox${i}.jpg?x-oss-process=image/resize,h_2048&version=2`
+  );
+
+const refreshScenes = async (relicsId: number) => {
+  scenes.value = await relicsScenesFetch(relicsId);
+};
+
+export const initRelics = async (relicsId: number) => {
+  const data = await Promise.all([
+    relicsInfoFetch(relicsId),
+    refreshScenes(relicsId),
+  ]);
+  [relics.value] = data;
+};
+export const updateRelicsName = async (name: string) => {
+  await updateRelicsFetch({ ...relics.value!, name });
+  relics.value!.name = name;
+};
+
+export const addScene = async (sceneCode: string) => {
+  await addRelicsSceneFetch(relics.value!.relicsId, sceneCode);
+  await refreshScenes(relics.value!.relicsId);
+};
+export const delScene = async (scene: RelicsScene) => {
+  await delRelicsSceneFetch(relics.value!.relicsId, scene.id);
+  await refreshScenes(relics.value!.relicsId);
+};
+export const updateScenePointName = async (
+  point: RelicsScenePoint,
+  newName: string
+) => {
+  await updateRelicsScenePosNameFetch(point.id, newName);
+  if (relics.value) {
+    await refreshScenes(relics.value.relicsId);
+  }
+};
+
+export const gotoScene = (scene: RelicsScene) => {
+  const params = new URLSearchParams();
+  params.set("m", scene.sceneCode);
+  params.set("token", "xxx");
+  params.set("lang", "zh");
+  if (scene.sceneCode.startsWith("KJ")) {
+    window.open(`https://www.4dkankan.com/spg.html?` + params.toString());
+  } else {
+    window.open(`https://laser.4dkankan.com/?` + params.toString());
+  }
+};

+ 37 - 0
src/store/user.ts

@@ -0,0 +1,37 @@
+import { LoginProps, loginFetch, userInfoFetch } from "@/request";
+import { errorHook, gHeaders } from "@/request/state";
+import { UserInfo } from "@/request/type";
+import { encodePwd } from "@/util";
+import { ref } from "vue";
+
+export const user = ref<UserInfo>();
+
+export const login = async (props: LoginProps) => {
+  const data = await loginFetch({
+    ...props,
+    password: encodePwd(props.password),
+  });
+  user.value = data.user;
+  gHeaders.token = data.token;
+  localStorage.setItem("token", data.token);
+  getUserInfo();
+};
+
+export const logout = () => {
+  localStorage.removeItem("token");
+};
+
+export const getUserInfo = async () => {
+  user.value = await userInfoFetch();
+};
+
+errorHook.push((code) => {
+  if (code === 4008) {
+    logout();
+  }
+});
+{
+  const token = localStorage.getItem("token");
+  token && (gHeaders.token = token);
+  getUserInfo();
+}

+ 20 - 0
src/style.scss

@@ -0,0 +1,20 @@
+html,
+body,
+#app {
+  margin: 0;
+  width : 100vw;
+  height: 100vh;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+#app {
+  overflow-y: auto;
+}
+
+.disable {
+  opacity       : 0.7;
+  pointer-events: none;
+}

+ 187 - 0
src/util/file-serve.ts

@@ -0,0 +1,187 @@
+function bom(blob: any, opts: any) {
+  if (typeof opts === "undefined") opts = { autoBom: false };
+  else if (typeof opts !== "object") {
+    console.warn("Deprecated: Expected third argument to be a object");
+    opts = { autoBom: !opts };
+  }
+
+  if (
+    opts.autoBom &&
+    /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(
+      blob.type
+    )
+  ) {
+    return new Blob([String.fromCharCode(0xfeff), blob], { type: blob.type });
+  }
+  return blob;
+}
+
+function download(
+  url: any,
+  name: any,
+  opts: any,
+  onprogress: any
+): Promise<void> {
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    xhr.open("GET", url);
+    xhr.responseType = "blob";
+    xhr.onload = function () {
+      saveAs(xhr.response, name, opts).then(resolve);
+    };
+    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: any) {
+  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: any) {
+  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;
+const global = window;
+let lastName: string = "";
+
+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;

+ 308 - 0
src/util/gl.ts

@@ -0,0 +1,308 @@
+import { mat4, vec3 } from "gl-matrix";
+
+export type VaoBuffers<T extends string> = { [key in T]: WebGLBuffer };
+export const updateVao = <T extends string>(
+  gl: WebGL2RenderingContext,
+  modal: { [key in T]: ArrayBufferView },
+  pointers: Pointer<T>[],
+  vao: WebGLVertexArrayObject
+) => {
+  gl.bindVertexArray(vao);
+
+  const keys = pointers.map((p) => p.key);
+  const buffers = Object.fromEntries(
+    Object.entries(modal)
+      .filter(([k]) => keys.includes(k as T))
+      .map(([key, val]) => {
+        const buffer = gl.createBuffer();
+        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+        gl.bufferData(gl.ARRAY_BUFFER, val as ArrayBufferView, gl.STATIC_DRAW);
+        return [key, buffer];
+      })
+  ) as VaoBuffers<T>;
+
+  for (const pointer of pointers) {
+    gl.bindBuffer(gl.ARRAY_BUFFER, buffers[pointer.key]);
+    gl.enableVertexAttribArray(pointer.loc);
+    gl.vertexAttribPointer(
+      pointer.loc,
+      pointer.size,
+      pointer.type,
+      false,
+      pointer.stride,
+      pointer.offset
+    );
+
+    if (pointer.divisor && "divisor" in pointer) {
+      gl.vertexAttribDivisor(pointer.loc, pointer.divisor!);
+    }
+  }
+  return buffers;
+};
+
+export type Pointer<T = string> = {
+  loc: number;
+  key: T;
+  size: number;
+  type: number;
+  stride: number;
+  offset: number;
+  divisor?: number;
+};
+export const generateVao = <T extends string>(
+  gl: WebGL2RenderingContext,
+  modal: { [key in T]: ArrayBufferView },
+  pointers: Pointer<T>[]
+) => {
+  const vao = gl.createVertexArray()!;
+  updateVao(gl, modal, pointers, vao);
+
+  if ("includes" in modal) {
+    const eleBuffer = gl.createBuffer();
+    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, eleBuffer);
+    gl.bufferData(
+      gl.ELEMENT_ARRAY_BUFFER,
+      (modal as any).includes,
+      gl.STATIC_DRAW
+    );
+  }
+
+  return vao;
+};
+
+export type Uniforms = {
+  [key in string]:
+    | number
+    | number[]
+    | vec3[]
+    | number[][]
+    | Float32Array
+    | Float32Array[]
+    | Uniforms
+    | Uniforms[]
+    | mat4[];
+};
+
+const setUniform = (
+  gl: WebGL2RenderingContext,
+  program: WebGLProgram,
+  key: string,
+  valR: number | number[] | Float32Array
+) => {
+  const val = (
+    valR instanceof Float32Array || Array.isArray(valR) ? valR : [valR]
+  ) as number[];
+
+  const loc = gl.getUniformLocation(program, key);
+
+  if (loc) {
+    try {
+      if (/(mat$)|(mats\[\d+\])/gi.test(key) && (valR as any).length) {
+        (gl as any)[`uniformMatrix${Math.sqrt(val.length)}fv`](loc, false, val);
+      } else if (key.includes("Tex") || key.includes("tex")) {
+        gl.uniform1iv(loc, val);
+      } else if (val.length > 4) {
+        for (let i = 0; i < val.length; i++) {
+          setUniform(gl, program, `${key}[${i}]`, val[i]);
+        }
+      } else {
+        (gl as any)[`uniform${val.length}fv`](loc, val);
+      }
+    } catch (e) {
+      console.error(`key in ${key} val in`, val);
+      throw e;
+    }
+  } else {
+    // console.log(key, loc);
+  }
+};
+
+export const setUniforms = (
+  gl: WebGL2RenderingContext,
+  program: WebGLProgram,
+  data: Uniforms,
+  prefix = ""
+) => {
+  Object.entries(data).forEach(([k, v]) => {
+    if (
+      v instanceof Float32Array ||
+      Array.isArray(v) ||
+      typeof v !== "object"
+    ) {
+      if (Array.isArray(v) && typeof v[0] === "object") {
+        v.forEach((vi, ndx) => {
+          if (vi instanceof Float32Array) {
+            setUniform(gl, program, prefix + k + `[${ndx}]`, vi);
+          } else {
+            setUniforms(gl, program, vi as Uniforms, prefix + k + `[${ndx}].`);
+          }
+        });
+      } else {
+        setUniform(gl, program, prefix + k, v as number);
+      }
+    } else {
+      setUniforms(gl, program, v as Uniforms, k + ".");
+    }
+  });
+};
+
+export const generateProgram = (
+  gl: WebGL2RenderingContext,
+  ...shaders: WebGLShader[]
+) => {
+  const program = gl.createProgram();
+  if (!program) throw "gl 无法创建程序";
+  for (const shader of shaders) {
+    gl.attachShader(program, shader);
+  }
+  gl.linkProgram(program);
+  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+    console.error(gl.getProgramInfoLog(program));
+    throw "程序链接失败";
+  }
+  return program;
+};
+
+const typeNameMap: { [key in string]: string } = {
+  35633: "顶点着色器",
+  35632: "片段着色器",
+};
+export const createShader = (
+  gl: WebGL2RenderingContext,
+  type: number,
+  source: string
+) => {
+  const shader = gl.createShader(type);
+  if (!shader) throw `gl 无法创建${typeNameMap[type]}着色器`;
+  gl.shaderSource(shader, source);
+  gl.compileShader(shader);
+
+  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+    console.error(gl.getShaderInfoLog(shader));
+    throw `${typeNameMap[type]}着色器编译失败`;
+  }
+
+  return shader;
+};
+
+export const createProgram = (
+  gl: WebGL2RenderingContext,
+  vSource: string,
+  fSource: string
+) => {
+  const program = generateProgram(
+    gl,
+    createShader(gl, gl.VERTEX_SHADER, vSource),
+    createShader(gl, gl.FRAGMENT_SHADER, fSource)
+  );
+  return program;
+};
+
+import { ReadonlyVec3, vec2 } from "gl-matrix";
+import { mergeFuns } from ".";
+
+export const createFPSCamera = (
+  mount: HTMLElement,
+  onChange: (viewMat: mat4, eys: vec3, front: vec3) => void,
+  worldUp = vec3.fromValues(0, 1, 0),
+  initEys: ReadonlyVec3 = vec3.fromValues(0, 0, 3),
+  initView: { pitch?: number; yaw?: number } = {},
+  maxDis = Infinity
+) => {
+  const up = vec3.fromValues(0, 1, 0);
+  const eys = vec3.fromValues(initEys[0], initEys[1], initEys[2]);
+  // 向量,前沿方向
+  const front = vec3.fromValues(0, 0, -1);
+  // 俯仰视角
+  let pitch = initView.pitch || 0;
+  // 偏航角
+  let yaw = initView.yaw || -Math.PI / 2;
+
+  const cameraMat = mat4.identity(mat4.create());
+  const updateCameraMat = () => {
+    const target = vec3.add(vec3.create(), eys, front);
+    mat4.lookAt(cameraMat, eys, target, up);
+    onChange(cameraMat, eys, front);
+  };
+  const updateFront = () => {
+    front[0] = Math.cos(pitch) * Math.cos(yaw);
+    front[1] = Math.sin(pitch);
+    front[2] = Math.cos(pitch) * Math.sin(yaw);
+    // console.log(pitch, yaw);
+    vec3.normalize(front, front);
+    vec3.cross(up, vec3.cross(vec3.create(), front, worldUp), front);
+  };
+
+  const start = vec2.create();
+  const mousedownHandler = (ev: MouseEvent) => {
+    start[0] = ev.offsetX;
+    start[1] = ev.offsetY;
+
+    mount.addEventListener("mousemove", mouseMoveHandler);
+    mount.addEventListener("mouseup", mouseUpHandler);
+  };
+
+  const rotatePixelAmount = 1500;
+  const mouseMoveHandler = (ev: MouseEvent) => {
+    const end = vec2.fromValues(ev.offsetX, ev.offsetY);
+    const move = vec2.sub(vec2.create(), end, start);
+    pitch += (move[1] / rotatePixelAmount) * Math.PI;
+    if (pitch > 89) {
+      pitch = 89;
+    } else if (pitch < -89) {
+      pitch = -89;
+    }
+    yaw -= (move[0] / rotatePixelAmount) * Math.PI;
+    start[0] = end[0];
+    start[1] = end[1];
+    updateFront();
+    updateCameraMat();
+  };
+
+  const mouseUpHandler = () => {
+    mount.removeEventListener("mousemove", mouseMoveHandler);
+    mount.removeEventListener("moseup", mouseUpHandler);
+  };
+  const wheelHandler = (ev: WheelEvent) => {
+    const amount = ev.deltaY * -0.01;
+    const move = vec3.scale(vec3.create(), front, amount);
+    const neys = vec3.create();
+    vec3.add(neys, eys, move);
+    if (vec3.length(neys) <= maxDis) {
+      vec3.copy(eys, neys);
+      updateCameraMat();
+    }
+  };
+
+  mount.addEventListener("mousedown", mousedownHandler);
+  document.addEventListener("wheel", wheelHandler);
+
+  setTimeout(() => {
+    updateFront();
+    updateCameraMat();
+  });
+
+  return {
+    recovery() {
+      vec3.copy(eys, vec3.fromValues(initEys[0], initEys[1], initEys[2]));
+      pitch = initView.pitch || 0;
+      yaw = initView.yaw || -Math.PI / 2;
+      updateFront();
+      updateCameraMat();
+    },
+    destory: mergeFuns(mouseUpHandler, () =>
+      document.removeEventListener("wheel", wheelHandler)
+    ),
+  };
+};
+
+export const useTex = (
+  gl: WebGL2RenderingContext,
+  tex: WebGLTexture,
+  target: number = gl.TEXTURE_2D,
+  offset = 0
+) => {
+  gl.activeTexture(gl.TEXTURE0 + offset);
+  gl.bindTexture(target, tex);
+  return offset;
+};

+ 231 - 0
src/util/index.ts

@@ -0,0 +1,231 @@
+import { Base64 } from "js-base64";
+
+export const getRealativeMosePosition = (
+  dom: HTMLElement,
+  mousePosition: number[]
+) => {
+  const rect = dom.getBoundingClientRect();
+  return [mousePosition[0] - rect.left, mousePosition[1] - rect.top];
+};
+
+// 节流
+export const throttle = <T extends (...args: any) => any>(
+  fn: T,
+  delay: number = 160
+) => {
+  let previous = 0;
+
+  return function <Args extends Array<any>, This>(
+    this: This,
+    ...args: Parameters<T>
+  ) {
+    const now = Date.now();
+    if (now - previous >= delay) {
+      fn.apply(this, args);
+      previous = now;
+    }
+  };
+};
+
+// 防抖
+export const debounce = <T extends (...args: any) => any>(
+  fn: T,
+  delay: number = 160
+) => {
+  let timeout: any;
+
+  return function <This>(this: This, ...args: Parameters<T>) {
+    clearTimeout(timeout);
+    timeout = setTimeout(() => {
+      fn.apply(this, args);
+    }, delay);
+  };
+};
+
+export const loadImage = (src: string): Promise<HTMLImageElement> => {
+  const img = new Image();
+  img.src = src;
+  img.crossOrigin = "anonymous";
+  return new Promise<HTMLImageElement>((resolve) => {
+    img.onload = () => resolve(img);
+  });
+};
+
+export const mergeFuns = (...fns: (() => void)[]) => {
+  return () => {
+    fns.forEach((fn) => fn());
+  };
+};
+
+// 四舍五入保留指定位数
+export const round = (num: number, index: number = 2) => {
+  const s = Math.pow(10, index);
+  return Math.round(num * s) / s;
+};
+
+export const numberSplice = (val: number) => {
+  const integer = Math.floor(val);
+  const decimal = val - integer;
+
+  return [integer, decimal];
+};
+
+//经纬度转度°分′秒″
+export const toDegrees = (val: number, retain = 4) => {
+  let temps = numberSplice(val);
+  const d = temps[0];
+  temps = numberSplice(temps[1] * 60);
+  const m = temps[0];
+  const s = round(temps[1] * 60, retain);
+
+  return `${d}°${m}'${s}"`;
+};
+
+export const copyText = async (
+  text: string,
+  fallback?: boolean
+): Promise<void> => {
+  if (navigator.clipboard && !fallback) {
+    let permiss: any;
+    try {
+      permiss = await navigator.permissions.query({
+        name: "geolocation",
+      });
+      permiss.state === "denied";
+    } catch (e) {
+      console.error(e);
+    }
+
+    if (permiss && permiss.state === "denied") {
+      console.error(permiss);
+      throw new Error("请授予写入粘贴板权限!");
+    } else {
+      try {
+        await navigator.clipboard.writeText(text);
+      } catch (e) {
+        console.error("不支持navigator.clipboard.writeText 开启回退");
+        return await copyText(text, true);
+      }
+    }
+  } else {
+    const textarea = document.createElement("textarea");
+    document.body.appendChild(textarea);
+    // 隐藏此输入框
+    textarea.style.position = "fixed";
+    textarea.style.clip = "rect(0 0 0 0)";
+    textarea.style.top = "10px";
+    // 赋值
+    textarea.value = text;
+    // 选中
+    textarea.select();
+    // 复制
+    document.execCommand("copy", true);
+    // 移除输入框
+    document.body.removeChild(textarea);
+  }
+};
+
+function randomWord(randomFlag: boolean, min: number, max?: number) {
+  let str = "";
+  let range = min;
+  const arr = [
+    "0",
+    "1",
+    "2",
+    "3",
+    "4",
+    "5",
+    "6",
+    "7",
+    "8",
+    "9",
+    "a",
+    "b",
+    "c",
+    "d",
+    "e",
+    "f",
+    "g",
+    "h",
+    "i",
+    "j",
+    "k",
+    "l",
+    "m",
+    "n",
+    "o",
+    "p",
+    "q",
+    "r",
+    "s",
+    "t",
+    "u",
+    "v",
+    "w",
+    "x",
+    "y",
+    "z",
+    "A",
+    "B",
+    "C",
+    "D",
+    "E",
+    "F",
+    "G",
+    "H",
+    "I",
+    "J",
+    "K",
+    "L",
+    "M",
+    "N",
+    "O",
+    "P",
+    "Q",
+    "R",
+    "S",
+    "T",
+    "U",
+    "V",
+    "W",
+    "X",
+    "Y",
+    "Z",
+  ];
+  // 随机产生
+  if (randomFlag && max) {
+    range = Math.round(Math.random() * (max - min)) + min;
+  }
+  for (let i = 0; i < range; i++) {
+    const pos = Math.round(Math.random() * (arr.length - 1));
+    str += arr[pos];
+  }
+  return str;
+}
+
+export function encodePwd(str: string) {
+  str = Base64.encode(str);
+  const NUM = 2;
+  const front = randomWord(false, 8);
+  const middle = randomWord(false, 8);
+  const end = randomWord(false, 8);
+
+  const str1 = str.substring(0, NUM);
+  const str2 = str.substring(NUM);
+
+  return front + str2 + middle + str1 + end;
+}
+
+const place = /(?:\/:([^/]*))/g;
+// 生成/:id 类真实url
+export const gendUrl = (tempUrl: string, params: { [key: string]: any }) => {
+  let url = "";
+  let preIndex = 0;
+  let m;
+  while ((m = place.exec(tempUrl))) {
+    url += tempUrl.substring(preIndex, m.index + 1) + (params[m[1]] || "null");
+    preIndex = m.index + m[0].length;
+  }
+  url += tempUrl.substr(preIndex);
+  return url;
+};

+ 42 - 0
src/util/pc4xlsl.ts

@@ -0,0 +1,42 @@
+import * as XLSX from "xlsx";
+import { round, toDegrees } from "./";
+import { saveAs } from "./file-serve";
+
+export const downloadPointsXLSL = async (
+  points: number[][],
+  desc: { title: string; desc: string }[] = [],
+  name: string
+) => {
+  const data = await fetch("/template.xls").then((r) => r.arrayBuffer());
+  const workbook = XLSX.read(data);
+  const sheetName = workbook.SheetNames[0];
+  const worksheet = workbook.Sheets[sheetName];
+  const tabs = points.map((point, i) => {
+    const des = desc[i] || { title: "无", desc: "无" };
+    return [
+      toDegrees(point[0], 4),
+      toDegrees(point[1], 4),
+      round(point[2], 4),
+      des.title,
+      des.desc,
+    ];
+  });
+  XLSX.utils.sheet_add_aoa(worksheet, tabs, { origin: "A2" });
+
+  const wbout = XLSX.write(workbook, {
+    // 要生成的文件类型
+    bookType: "xlsx",
+    type: "binary",
+  });
+  // 将字符串转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 saveAs(blob, `${name}.xls`);
+};

+ 82 - 0
src/view/layout/nav.vue

@@ -0,0 +1,82 @@
+<template>
+  <div class="main-layout">
+    <div class="header" :class="{ [name]: true }">
+      <el-button :icon="Back" circle type="primary" @click="router.back()" />
+      <el-dropdown class="avatar" v-if="user">
+        <span>
+          <el-avatar :src="user.head" />
+        </span>
+        <template #dropdown>
+          <el-dropdown-menu>
+            <el-dropdown-item @click="logoutHandler">退出登录</el-dropdown-item>
+          </el-dropdown-menu>
+        </template>
+      </el-dropdown>
+    </div>
+    <div class="content">
+      <RouterView v-slot="{ Component }">
+        <KeepAlive>
+          <component :is="Component" />
+        </KeepAlive>
+      </RouterView>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Back } from "@element-plus/icons-vue";
+import { router } from "@/router";
+import { computed } from "vue";
+import { user, logout } from "@/store/user";
+import { errorHook } from "@/request/state";
+
+const name = computed(() => router.currentRoute.value.name as string);
+const logoutHandler = () => {
+  logout();
+  router.replace("login");
+};
+errorHook.push((code) => {
+  if (code === 4008) {
+    router.replace("login");
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.main-layout {
+  --border-color: #dcdfe6;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+.header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 4px 10px;
+
+  &:not(.pano, .map) {
+    border-bottom: 1px solid var(--border-color);
+    flex: 0 0 auto;
+  }
+  &.pano,
+  &.map {
+    pointer-events: none;
+    z-index: 9;
+    position: absolute;
+    width: 100%;
+
+    .avatar {
+      display: none;
+    }
+    * {
+      pointer-events: all;
+    }
+  }
+}
+
+.content {
+  flex: 1;
+  overflow: hidden;
+}
+</style>

+ 318 - 0
src/view/login.vue

@@ -0,0 +1,318 @@
+<template>
+  <div class="system-layer" :style="{ backgroundImage: `url(/criminalBanner.png)` }">
+    <div class="l-content">
+      <div class="login-layer">
+        <div class="content">
+          <div class="info">
+            <h1>不移动动文物管理平台</h1>
+            <p>Non mobile cultural relic management platform</p>
+          </div>
+          <el-form class="panel login" :model="form" @submit.stop>
+            <h2>欢迎登录</h2>
+            <el-form-item class="panel-form-item">
+              <p class="err-info">{{ verification.phone }}</p>
+              <el-input
+                :maxlength="11"
+                v-model.trim="form.phone"
+                placeholder="手机号"
+                @keydown.enter="submitClick"
+              ></el-input>
+            </el-form-item>
+            <el-form-item class="panel-form-item">
+              <p class="err-info">{{ verification.psw }}</p>
+              <el-input
+                v-model="form.psw"
+                :maxlength="16"
+                placeholder="密码"
+                :type="flag ? 'password' : 'text'"
+                @keydown.enter="submitClick"
+              >
+                <template v-slot:suffix>
+                  <el-icon :size="20" @click="flag = !flag" class="icon-style">
+                    <View v-if="flag" />
+                    <Hide v-else />
+                  </el-icon>
+                </template>
+              </el-input>
+            </el-form-item>
+
+            <el-form-item class="panel-form-item">
+              <el-button type="primary" class="fill" @click="submitClick">登录</el-button>
+            </el-form-item>
+          </el-form>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, watch, ref } from "vue";
+import { View, Hide } from "@element-plus/icons-vue";
+import { login } from "@/store/user";
+import { ElMessage } from "element-plus";
+import { router } from "@/router";
+
+const PHONE = {
+  REG: /^1(3|4|5|6|7|8|9)\d{9}$/,
+  // REG: /^((13[0-9]|14[01456879]|15[0-3,5-9]|16[2567]|17[0-8]|18[0-9]|19[0-3,5-9])\d{8})|(8){11}$/,
+  tip: "手机号格式不正确!",
+};
+// 是否显示明文密码
+const flag = ref(true);
+// 表单
+const form = reactive({
+  phone: "15915816041",
+  psw: "4DAge123456",
+});
+const verification = reactive({ phone: "", psw: "" });
+// 验证
+watch(
+  form,
+  () => {
+    if (!form.phone) {
+      verification.phone = "请输入手机号";
+    } else if (form.phone == "88888888888") {
+      verification.phone = "";
+    } else {
+      verification.phone = PHONE.REG.test(form.phone) ? "" : PHONE.tip;
+    }
+    if (!form.psw) {
+      verification.psw = "请输入密码";
+    } else {
+      verification.psw = "";
+    }
+  },
+  { immediate: true }
+);
+
+// 表单提交
+const submitClick = async () => {
+  if (verification.phone && verification.phone !== "88888888888") {
+    return ElMessage.error(verification.phone);
+  }
+  if (verification.psw) return ElMessage.error(verification.psw);
+
+  try {
+    await login({ phoneNum: form.phone, password: form.psw });
+    router.replace("relics");
+  } catch (e) {
+    console.error(e);
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.login-layer {
+  text-align: right;
+  width: 100%;
+  height: 100%;
+}
+.content {
+  display: flex;
+  justify-content: center;
+  align-items: flex-start;
+}
+.info {
+  color: #000;
+  margin-right: 143px;
+  padding-top: 80px;
+  padding-bottom: 80px;
+  flex: none;
+  text-align: left;
+  img {
+    width: 76px;
+    height: 76px;
+  }
+  h1 {
+    font-size: 2.8rem;
+    line-height: 3.7rem;
+    margin-bottom: 0.7rem;
+  }
+  p {
+    font-size: 2rem;
+    line-height: 2.2rem;
+  }
+}
+
+.top-text {
+  margin-bottom: 50px;
+  pointer-events: none;
+  height: 153px;
+  min-width: 1200px;
+  img {
+    position: absolute;
+    right: 0;
+  }
+}
+.fill {
+  width: 100%;
+}
+.login {
+  width: 400px;
+  padding: 40px 40px 30px;
+  position: relative;
+  display: inline-block;
+
+  h2 {
+    padding-left: 0;
+    padding-bottom: 0;
+    border-bottom: none;
+    margin-bottom: 2.14rem;
+
+    span {
+      color: #646566;
+      font-size: 1.33rem;
+      margin-top: 0.71rem;
+      display: block;
+    }
+  }
+
+  .panel-form-item {
+    padding-left: 0;
+    padding-right: 0;
+    .icon-style {
+      margin-right: 14px;
+      font-size: 20px;
+      line-height: 50px;
+    }
+  }
+
+  .more a:first-child::after {
+    content: "";
+    position: absolute;
+    right: -5px;
+    width: 1px;
+    height: 8px;
+    background: #dcdee0;
+    top: 50%;
+    transform: translateY(-50%);
+  }
+}
+
+.code-img {
+  width: 100%;
+  height: 100%;
+  // object-fit: cover;
+}
+
+.system-layer {
+  width: 100%;
+  min-height: 100%;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  background: no-repeat left bottom;
+  background-size: cover;
+}
+.l-content {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  justify-content: center;
+  align-items: flex-start;
+}
+</style>
+
+<style>
+.login .code-form-item .el-input {
+  display: flex;
+}
+
+.login .code-form-item .el-input-group__append {
+  flex: none;
+  margin-left: 10px;
+  width: 95px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+}
+
+.login .code-form-item .el-input__inner {
+  flex: 1;
+}
+.login .code-form-item .el-input-group__append,
+.login .code-form-item .el-input__inner {
+  border-radius: 4px;
+}
+input[type="password"]::-ms-reveal {
+  display: none;
+}
+
+.panel {
+  background: rgba(255, 255, 255, 0.7);
+  box-shadow: 0px 2px 20px 0px rgba(5, 38, 38, 0.15);
+  border-radius: 10px;
+  width: 600px;
+  padding: 30px 0 40px;
+  text-align: initial;
+
+  h2 {
+    color: #323233;
+    font-size: 1.85rem;
+    margin-bottom: 2.14rem;
+    font-weight: normal;
+    padding-left: 60px;
+    padding-bottom: 20px;
+    border-bottom: 1px solid #e9e9e9;
+  }
+
+  .panel-form-item {
+    position: relative;
+    padding-bottom: 2.14rem;
+    margin: 0;
+    padding-left: 90px;
+    padding-right: 90px;
+
+    &.remember {
+      padding: 0;
+    }
+
+    .err-info {
+      position: absolute;
+      top: 100%;
+      left: 20px;
+      margin: 0;
+      font-size: 1rem;
+      line-height: 2.14rem;
+      color: #fa5555;
+    }
+  }
+
+  .more {
+    text-align: center;
+
+    a {
+      color: #323233;
+      line-height: 21px;
+      font-size: 16px;
+      margin: 0 5px;
+      position: relative;
+      text-decoration: none;
+      cursor: pointer;
+    }
+  }
+}
+
+.panel-form-item .el-select {
+  width: 100%;
+}
+
+.panel-form-item .el-button,
+.panel-form-item .el-input__inner {
+  height: 50px;
+  font-size: 1.14rem;
+}
+
+.panel-form-item .el-button {
+  line-height: 26px;
+  font-weight: bold;
+  font-size: 16px;
+}
+
+.panel-form-item .el-form-item__label {
+  line-height: 50px;
+}
+</style>

+ 87 - 0
src/view/map/hot.ts

@@ -0,0 +1,87 @@
+import { Style, Icon } from "ol/style";
+import { Geometry, Point } from "ol/geom";
+import { Feature, Map } from "ol";
+import { Vector } from "ol/layer";
+import { Vector as SourceVector } from "ol/source";
+import { debounce, getRealativeMosePosition } from "@/util";
+import mitt from "mitt";
+
+const styles = [
+  new Style({
+    image: new Icon({
+      src: "/map/point_a.png",
+      scale: [0.5, 0.5],
+      offset: [0, 0],
+    }),
+  }),
+];
+
+const getHotFeature = (pos: number[]) => {
+  const feature = new Feature(new Point(pos));
+  feature.setStyle(styles[0]);
+  return feature;
+};
+
+const hotSource = new SourceVector();
+export const hotLayer = new Vector({ source: hotSource });
+export type HotData = { data: number[]; id: any; label: string };
+export const addHots = (posArray: HotData[]) => {
+  hotSource.addFeatures(
+    posArray.map((item) => {
+      const feature = getHotFeature(item.data);
+      feature.setProperties({ id: item.id });
+      return feature;
+    })
+  );
+};
+
+export const delHots = (ids: HotData["id"][]) => {
+  const features = hotSource
+    .getFeatures()
+    .filter((f) => ids.includes(f.getProperties().id));
+  hotSource.removeFeatures(features);
+};
+
+export const clearHots = () => {
+  hotSource.removeFeatures(hotSource.getFeatures());
+};
+
+export const dynamicHots = (map: Map) => {
+  const bus = mitt<{ active: any; click: any }>();
+  const container = map.getTargetElement();
+  const getMouseFeature = (ev: MouseEvent) => {
+    const pixel = getRealativeMosePosition(container, [ev.offsetX, ev.offsetY]);
+    return map.forEachFeatureAtPixel(pixel, (feature) => {
+      return hotSource.getFeatures().find((f) => f === feature);
+    });
+  };
+
+  const addEventListener = (
+    ev: "mousemove" | "click",
+    emitKey: "active" | "click",
+    repeat = false
+  ) => {
+    let last: Feature<Geometry> | null = null;
+    container.addEventListener(
+      ev,
+      debounce((ev) => {
+        const feature = getMouseFeature(ev);
+
+        if (feature) {
+          if (repeat || feature !== last) {
+            bus.emit(emitKey, feature.getProperties()["id"]);
+            last = feature;
+          }
+        } else if (repeat || last) {
+          bus.emit(emitKey, null);
+          last = null;
+        }
+      }, 32)
+    );
+
+    return last!;
+  };
+  addEventListener("click", "click", true);
+  addEventListener("mousemove", "active");
+  return bus;
+};

+ 6 - 0
src/view/map/index.ts

@@ -0,0 +1,6 @@
+import { Manage } from "./manage";
+export type { TileType } from "./tile";
+
+export const createMap = (dom: HTMLDivElement) => {
+  return new Manage(dom);
+};

+ 67 - 0
src/view/map/manage.ts

@@ -0,0 +1,67 @@
+import { Map, View } from "ol";
+import { TileType, baseTileLayer, geoTileLayer, setBaseTileType } from "./tile";
+import {
+  HotData,
+  addHots,
+  delHots,
+  hotLayer,
+  dynamicHots,
+  clearHots,
+} from "./hot";
+import { Emitter } from "mitt";
+
+const createMap = (container: HTMLDivElement) => {
+  const view = new View({
+    center: [113.59562585879772, 22.367660742553472],
+    projection: "EPSG:4326",
+    zoom: 18,
+  });
+
+  return new Map({
+    layers: [baseTileLayer, geoTileLayer, hotLayer],
+    view,
+    target: container,
+    controls: [],
+  });
+};
+
+export class Manage {
+  map: Map;
+  hotsBus: Emitter<{
+    active: any;
+    click: any;
+  }>;
+
+  constructor(container: HTMLDivElement) {
+    this.map = createMap(container);
+    this.hotsBus = dynamicHots(this.map);
+  }
+
+  setTileType(type: TileType) {
+    setBaseTileType(type);
+    this.map.render();
+  }
+
+  addHots(items: HotData[]) {
+    addHots(items);
+    this.map.render();
+  }
+
+  clearHots() {
+    clearHots();
+    this.map.render();
+  }
+
+  setCenter(center: number[]) {
+    this.map.getView().setCenter(center);
+  }
+
+  delHots(ids: HotData["id"][]) {
+    delHots(ids);
+    this.map.render();
+  }
+
+  render() {
+    this.map.render();
+  }
+}

+ 209 - 0
src/view/map/map-right.vue

@@ -0,0 +1,209 @@
+<template>
+  <div class="right-layout">
+    <div class="right-content">
+      <el-form :inline="false">
+        <el-form-item v-if="relics">
+          <el-input v-model="relicsName" :maxlength="50" placeholder="不可移动文物名称">
+            <template #append>
+              <el-button type="primary" @click="updateRelics">修改</el-button>
+            </template>
+          </el-input>
+        </el-form-item>
+        <el-form-item>
+          <el-button
+            type="primary"
+            :icon="Plus"
+            style="width: 100%"
+            @click="addSceneMode = true"
+          >
+            添加场景
+          </el-button>
+        </el-form-item>
+      </el-form>
+      <div class="tree-layout">
+        <p>全部数据</p>
+        <el-tree
+          style="max-width: 600px"
+          :data="treeNode"
+          node-key="id"
+          ref="treeRef"
+          show-checkbox
+          default-expand-all
+          :expand-on-click-node="false"
+        >
+          <template #default="{ node, data }">
+            <div
+              class="tree-item"
+              @click="!data.disable && emit((data.type === 'scene' ? 'flyScene' : 'flyPoint') as any, data.raw)"
+            >
+              <span :class="{ disable: data.disable }">
+                <el-icon>
+                  <Grid v-if="data.type === 'scene'" />
+                  <LocationInformation v-else />
+                </el-icon>
+                {{ node.label }}
+              </span>
+              <span>
+                <el-icon color="#409efc" v-if="data.type === 'scene'">
+                  <Delete @click.stop="delScene(data.raw)" />
+                </el-icon>
+                <el-icon v-else color="#409efc">
+                  <Edit @click.stop="inputPoint = data.raw" />
+                </el-icon>
+                <el-icon color="#409efc" style="margin-left: 8px">
+                  <Link
+                    @click.stop="
+                      data.type === 'scene'
+                        ? gotoScene(data.raw)
+                        : emit('gotoPoint', data.raw)
+                    "
+                  />
+                </el-icon>
+              </span>
+            </div>
+          </template>
+        </el-tree>
+      </div>
+    </div>
+
+    <el-button type="primary" :icon="Document" style="width: 100%" @click="exportFile">
+      导出四普数据
+    </el-button>
+  </div>
+
+  <SingleInput
+    :visible="!!inputPoint"
+    @update:visible="inputPoint = null"
+    :value="inputPoint?.name || ''"
+    :update-value="updatePointName"
+    title="修改点位名称"
+  />
+  <SingleInput
+    :visible="!!addSceneMode"
+    @update:visible="addSceneMode = false"
+    :value="''"
+    :update-value="addSceneHandler"
+    title="添加场景"
+  />
+</template>
+
+<script setup lang="ts">
+import {
+  Plus,
+  Delete,
+  Document,
+  Grid,
+  LocationInformation,
+  Edit,
+  Link,
+} from "@element-plus/icons-vue";
+import { computed, ref, watchEffect } from "vue";
+import {
+  RelicsScene,
+  scenes,
+  RelicsScenePoint,
+  addScene,
+  delScene,
+  updateScenePointName,
+  gotoScene,
+  relics,
+  updateRelicsName,
+} from "@/store/scene";
+import SingleInput from "@/components/single-input.vue";
+import { downloadPointsXLSL } from "@/util/pc4xlsl";
+import { ElMessage } from "element-plus";
+
+const emit = defineEmits<{
+  (e: "flyScene", data: RelicsScene): void;
+  (e: "flyPoint", data: RelicsScenePoint): void;
+  (e: "gotoPoint", data: RelicsScenePoint): void;
+}>();
+
+const inputPoint = ref<RelicsScenePoint | null>(null);
+const updatePointName = async (title: string) => {
+  await updateScenePointName(inputPoint.value!, title);
+};
+
+const addSceneMode = ref(false);
+const addSceneHandler = async (sceneCode: string) => {
+  const sceneTypes = ["SS", "KJ", "SG"];
+  if (sceneTypes.every((type) => !sceneCode.startsWith(type))) {
+    ElMessage.error("场景码不正确");
+    throw "场景码不正确";
+  } else {
+    await addScene(sceneCode);
+  }
+};
+
+const relicsName = ref("");
+watchEffect(() => (relicsName.value = relics.value?.name || ""));
+const updateRelics = async () => {
+  await updateRelicsName(relicsName.value);
+  ElMessage.success("修改成功");
+};
+
+const treeRef = ref<any>();
+const treeNode = computed(() =>
+  scenes.value.map((scene) => ({
+    label: scene.sceneCode,
+    id: scene.id,
+    type: "scene",
+    disable: scene.scenePos.every((pos) => !pos.pos || pos.pos.length === 0),
+    raw: scene,
+    children: scene.scenePos.map((pos) => ({
+      label: pos.name,
+      disable: !pos.pos || pos.pos.length === 0,
+      id: pos.id,
+      type: "point",
+      raw: pos,
+    })),
+  }))
+);
+
+const exportFile = async () => {
+  let points: RelicsScenePoint[] = treeRef
+    .value!.getCheckedNodes(false, false)
+    .filter((option: any) => option.type === "point")
+    .map((option: any) => option.raw);
+  if (!points.length) {
+    ElMessage.error("请选择要导出的点位");
+  }
+  points = points.filter((point) => !!point.pos);
+
+  if (points.length === 0) {
+    ElMessage.error("当前选择点位没有gis信息");
+  } else {
+    await downloadPointsXLSL(
+      points.map((point) => point.pos),
+      points.map((point) => ({ title: point.name, desc: point.name })),
+      "test"
+    );
+    ElMessage.success("文件导出成功");
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.tree-item {
+  display: flex;
+  width: 100%;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.tree-layout {
+  p {
+    color: #303133;
+    font-size: 14px;
+  }
+}
+.right-layout {
+  display: flex;
+  height: 100%;
+  flex-direction: column;
+  .right-content {
+    flex: 1;
+    overflow-y: auto;
+  }
+}
+</style>

+ 222 - 0
src/view/map/map.vue

@@ -0,0 +1,222 @@
+<template>
+  <div class="map-layout">
+    <div id="map" class="map-container" ref="container" :class="{ active: !!active }">
+      <div class="map-component">
+        <el-tooltip
+          class="tooltip"
+          :visible="!!active"
+          :content="active?.name"
+          effect="light"
+          placement="top"
+          virtual-triggering
+          :virtual-ref="triggerRef"
+        />
+        <el-select
+          v-model="tileType"
+          placeholder="选择底图"
+          style="width: 120px"
+          class="tile-type-select"
+        >
+          <el-option
+            v-for="item in tileOptions"
+            :key="item"
+            :label="item"
+            :value="item"
+          />
+        </el-select>
+      </div>
+    </div>
+    <div class="right-control">
+      <MapRight
+        @fly-point="flyScenePoint"
+        @fly-scene="flyScene"
+        @goto-point="gotoPoint"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import MapRight from "./map-right.vue";
+import { router, setDocTitle } from "@/router";
+import { TileType, createMap } from "./";
+import {
+  RelicsScenePoint,
+  RelicsScene,
+  scenePoints,
+  initRelics,
+  relics,
+  scenes,
+} from "@/store/scene";
+import { computed, onMounted, ref, watchEffect, watch } from "vue";
+import { Manage } from "./manage";
+
+const center = [109.47293862712675, 30.26530938156551];
+const active = ref<RelicsScenePoint | null>();
+const activePixel = ref<number[] | null>();
+const triggerRef = ref({
+  getBoundingClientRect() {
+    return DOMRect.fromRect({
+      x: activePixel.value![0],
+      y: activePixel.value![1],
+      width: 0,
+      height: 0,
+    });
+  },
+});
+
+const tileOptions: TileType[] = ["影像底图", "矢量底图"];
+const tileType = ref<TileType>(tileOptions[1]);
+
+const points = computed(() =>
+  scenePoints.value
+    .filter((point) => point.pos)
+    .map((point) => ({
+      data: point.pos,
+      id: point.id,
+      label: point.name,
+    }))
+);
+
+const gotoPoint = (point: RelicsScenePoint) => {
+  router.push({
+    name: "pano",
+    params: { pid: point.id },
+  });
+};
+
+const container = ref<HTMLDivElement>();
+let mapManage: Manage;
+onMounted(() => {
+  mapManage = createMap(container.value!);
+  mapManage.setCenter(center);
+  mapManage.hotsBus.on("active", (id) => {
+    if (id) {
+      const point = scenePoints.value.find((point) => point.id === id);
+      point && activeScenePoint(point);
+    } else {
+      active.value = null;
+      activePixel.value = null;
+    }
+  });
+  mapManage.hotsBus.on("click", (id) => {
+    const point = id && scenePoints.value.find((point) => point.id === id);
+    point && gotoPoint(point);
+  });
+  refreshHots();
+  refreshTileType();
+});
+
+const activeScenePoint = (point: RelicsScenePoint) => {
+  activePixel.value = mapManage.map.getPixelFromCoordinate(point.pos);
+  active.value = point;
+};
+
+const flyPos = (pos: number[]) => mapManage.map.getView().setCenter(pos);
+
+const flyScenePoint = (point: RelicsScenePoint) => {
+  flyPos(point.pos);
+  setTimeout(() => {
+    activeScenePoint(point);
+  }, 16);
+};
+
+const flyScene = (scene: RelicsScene) => {
+  const totalPos = [0, 0];
+  let numCalc = 0;
+  for (let i = 0; i < scene.scenePos.length; i++) {
+    totalPos[0] += scene.scenePos[i].pos[0];
+    totalPos[1] += scene.scenePos[i].pos[1];
+    numCalc++;
+  }
+
+  totalPos[0] /= numCalc;
+  totalPos[1] /= numCalc;
+  flyPos(totalPos);
+};
+
+const refreshHots = () => {
+  if (!mapManage) return;
+  mapManage.clearHots();
+  mapManage.addHots(points.value);
+};
+
+const refreshTileType = () => {
+  if (!mapManage) return;
+  mapManage.setTileType(tileType.value);
+};
+
+watch(points, refreshHots, { immediate: true });
+watch(tileType, refreshTileType, { immediate: true });
+watchEffect(() => {
+  if (router.currentRoute.value.name === "map") {
+    initRelics(Number(router.currentRoute.value.params.relicsId)).then(() => {
+      scenes.value.length && flyScene(scenes.value[0]);
+    });
+  }
+});
+watchEffect(() => {
+  if (router.currentRoute.value.name === "map" && relics.value) {
+    setDocTitle(relics.value.name);
+  }
+});
+</script>
+
+<style lang="scss">
+.tooltip {
+  pointer-events: none;
+}
+.map-layout {
+  display: flex;
+  flex-direction: row;
+  height: 100%;
+}
+
+.map-container {
+  flex: 1;
+  position: relative;
+}
+
+.right-control {
+  flex: 0 0 300px;
+  padding: 15px;
+
+  border-left: 1px solid var(--border-color);
+}
+
+.map-component {
+  width: 100%;
+  height: 100%;
+  position: relative;
+}
+
+.active {
+  cursor: pointer;
+}
+
+.active-point {
+  position: absolute;
+  pointer-events: none;
+}
+
+.map-component {
+  pointer-events: none;
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  left: 0;
+  top: 0;
+  z-index: 9;
+}
+.env {
+  width: 100%;
+  height: 100%;
+}
+
+.tile-type-select {
+  pointer-events: all;
+  position: absolute;
+  right: 10px;
+  top: 10px;
+}
+</style>

+ 85 - 0
src/view/map/tile.ts

@@ -0,0 +1,85 @@
+import { Tile } from "ol/layer";
+import TileWMTS from "ol/tilegrid/WMTS";
+import { WMTS } from "ol/source";
+import { Projection, get as getProjection, getTransform } from "ol/proj";
+import { register } from "ol/proj/proj4";
+import proj4 from "proj4";
+import { applyTransform, getTopLeft, getWidth } from "ol/extent";
+
+// 注册cgcs2000坐标转换器
+proj4.defs(
+  "EPSG:4490",
+  'GEOGCS["China Geodetic Coordinate System 2000",DATUM["China_2000",SPHEROID["CGCS2000",6378137,298.257222101,AUTHORITY["EPSG","1024"]],AUTHORITY["EPSG","1043"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4490"]]'
+);
+register(proj4);
+
+const getTileWMTSTileGrid = (from: Projection, to: Projection) => {
+  const projectionExtent = to.getExtent();
+  const origin = projectionExtent ? getTopLeft(projectionExtent) : [-180, 90];
+  const fromLonLat = getTransform(from, to);
+  const width = projectionExtent
+    ? getWidth(projectionExtent)
+    : getWidth(applyTransform([-180.0, -90.0, 180.0, 90.0], fromLonLat));
+
+  const resolutions = [];
+  const matrixIds = [];
+  for (let z = 1; z < 19; z++) {
+    resolutions[z] = width / (256 * Math.pow(2, z));
+    matrixIds[z] = z.toString();
+  }
+
+  return new TileWMTS({
+    origin: origin,
+    resolutions: resolutions,
+    matrixIds: matrixIds,
+  });
+};
+
+const tileTMaps = {
+  全球境界: "ibo",
+  地形注记: "cta",
+  地形晕渲: "ter",
+  影像注记: "cia",
+  影像底图: "img",
+  矢量注记: "cva",
+  矢量底图: "vec",
+};
+
+const wmtsProjection = getProjection("EPSG:4490")!;
+const tileGridCache: { [key in string]: TileWMTS } = {};
+const typeWMTSCacne: { [key in string]: WMTS } = {};
+
+const getWMTS = (type: TileType, mapEpsg: string) => {
+  if (typeWMTSCacne[mapEpsg + type]) {
+    return typeWMTSCacne[mapEpsg + type];
+  }
+  const wmtsTileGrid =
+    mapEpsg in tileGridCache
+      ? tileGridCache[mapEpsg]
+      : getTileWMTSTileGrid(getProjection(mapEpsg)!, wmtsProjection);
+
+  const layer = tileTMaps[type];
+  const key = "69167db5c31974a619fe60f0c4cd21b5";
+  const url = `http://t0.tianditu.gov.cn/${layer}_c/wmts?tk=${key}`;
+  return new WMTS({
+    url,
+    layer,
+    version: "1.0.0",
+    matrixSet: "c",
+    format: "tiles",
+    projection: wmtsProjection,
+    requestEncoding: "KVP",
+    style: "default",
+    tileGrid: wmtsTileGrid,
+  });
+};
+
+export type TileType = keyof typeof tileTMaps;
+
+export const baseTileLayer = new Tile();
+export const geoTileLayer = new Tile({
+  source: getWMTS("矢量注记", "EPSG:4326"),
+});
+export const setBaseTileType = (type: TileType) => {
+  baseTileLayer.setSource(getWMTS(type, "EPSG:4326"));
+};

+ 115 - 0
src/view/pano/env.ts

@@ -0,0 +1,115 @@
+import envFragSource from "./shader-env.frag?raw";
+import envVertSource from "./shader-env.vert?raw";
+import { loadImage } from "@/util";
+import { createFPSCamera, createProgram, generateVao, useTex } from "@/util/gl";
+import { mat4, glMatrix } from "gl-matrix";
+import { setUniforms } from "./setUniform";
+
+const generatePreset = (gl: WebGL2RenderingContext) => {
+  const skyCubeTex = gl.createTexture();
+  const updateSky = (images: HTMLImageElement[]) => {
+    gl.bindTexture(gl.TEXTURE_CUBE_MAP, skyCubeTex);
+    const mapper = [2, 4, 0, 5, 1, 3];
+    for (let i = 0; i < 6; i++) {
+      gl.texImage2D(
+        gl.TEXTURE_CUBE_MAP_POSITIVE_X + i,
+        0,
+        gl.RGB,
+        gl.RGB,
+        gl.UNSIGNED_BYTE,
+        images[mapper[i]]
+      );
+    }
+    gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+    gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+    gl.generateMipmap(gl.TEXTURE_CUBE_MAP);
+    gl.bindTexture(gl.TEXTURE_CUBE_MAP, null);
+  };
+
+  return {
+    skyCubeTex,
+    async preset(urls: string[]) {
+      const images = await Promise.all(urls.map(loadImage));
+      updateSky(images);
+    },
+  };
+};
+
+const getDrawVaring = (gl: WebGL2RenderingContext) => {
+  const positions = new Float32Array([
+    -1, -1, 1, 1, -1, 1, 1, 1, -1, -1, 1, -1,
+  ]);
+  const vao = generateVao(gl, { positions }, [
+    {
+      loc: 0,
+      key: "positions",
+      size: 2,
+      type: gl.FLOAT,
+      stride: 0,
+      offset: 0,
+    },
+  ]);
+  return {
+    ...generatePreset(gl),
+    vao,
+    numArrays: positions.length / 2,
+  };
+};
+
+export const init = (canvas: HTMLCanvasElement) => {
+  const size = [canvas.width, canvas.height];
+  const gl = canvas.getContext("webgl2")!;
+  const program = createProgram(gl, envVertSource, envFragSource);
+  const projectionMat = mat4.perspective(
+    mat4.create(),
+    glMatrix.toRadian(45),
+    size[0] / size[1],
+    0.1,
+    100
+  );
+  const invProjectionViewMat = mat4.create();
+  const viewMat = mat4.create();
+  const varing = getDrawVaring(gl);
+  const updateInv = () => {
+    mat4.multiply(invProjectionViewMat, projectionMat, viewMat);
+    mat4.invert(invProjectionViewMat, invProjectionViewMat);
+  };
+
+  const redraw = () => {
+    gl.clear(gl.COLOR_BUFFER_BIT);
+    gl.enable(gl.DEPTH_TEST);
+    gl.depthFunc(gl.LEQUAL);
+    gl.useProgram(program);
+    gl.viewport(0, 0, size[0], size[1]);
+    gl.bindVertexArray(varing.vao);
+    setUniforms(gl, program, {
+      invProjectionViewMat,
+      envTex: useTex(gl, varing.skyCubeTex!, gl.TEXTURE_CUBE_MAP),
+    });
+    gl.drawArrays(gl.TRIANGLES, 0, varing.numArrays);
+  };
+
+  const fps = createFPSCamera(
+    canvas.parentElement!,
+    (nViewMat) => {
+      mat4.copy(viewMat, nViewMat);
+      updateInv();
+      redraw();
+    },
+    [0, 1, 0],
+    [0, 0, 0],
+    {},
+    80
+  );
+  return {
+    redraw,
+    changeUrls(urls: string[]) {
+      fps.recovery();
+      return varing.preset(urls).then(redraw);
+    },
+    destory: fps.destory,
+  };
+};

+ 118 - 0
src/view/pano/pano.vue

@@ -0,0 +1,118 @@
+<template>
+  <div class="pano-layout" v-loading="loading">
+    <canvas ref="panoDomRef"></canvas>
+    <div class="btns">
+      <el-button
+        size="large"
+        style="margin-right: 20px; width: 100px"
+        @click="copyGis"
+        v-if="point?.pos"
+      >
+        复制经纬度
+      </el-button>
+      <el-button size="large" type="primary" style="width: 100px" @click="update = true">
+        修改名称
+      </el-button>
+    </div>
+  </div>
+  <SingleInput
+    v-if="point"
+    :visible="update"
+    @update:visible="update = false"
+    :value="point.name || ''"
+    :update-value="tex => updateScenePointName(point!, tex)"
+    title="修改点位名称"
+  />
+</template>
+
+<script setup lang="ts">
+import SingleInput from "@/components/single-input.vue";
+import { router, setDocTitle } from "@/router";
+import { mergeFuns } from "@/util";
+import { computed, onMounted, onUnmounted, ref, watchEffect } from "vue";
+import { init } from "./env";
+import {
+  scenePoints,
+  updateScenePointName,
+  getPointPano,
+  RelicsScenePoint,
+} from "@/store/scene";
+import { copyText, toDegrees } from "@/util";
+import { ElMessage } from "element-plus";
+import { relicsScenePosInfoFetch } from "@/request";
+
+type Params = { pid?: string } | null;
+const params = computed(() => router.currentRoute.value.params as Params);
+const panoDomRef = ref<HTMLCanvasElement>();
+const destroyFns: (() => void)[] = [];
+const point = ref<RelicsScenePoint>();
+watchEffect(() => {
+  if (params.value?.pid) {
+    const pid = Number(params.value!.pid);
+    const cachePoint = scenePoints.value.find((point) => point.id === pid);
+    if (!cachePoint) {
+      relicsScenePosInfoFetch(pid).then((data) => (point.value = data));
+    } else {
+      point.value = cachePoint;
+    }
+  }
+});
+
+const panoUrls = computed(
+  () => point.value && getPointPano(point.value.sceneCode, Number(point.value.uuid))
+);
+const update = ref(false);
+const loading = ref(false);
+
+const copyGis = async () => {
+  const pos = point.value!.pos;
+  await copyText(
+    `经度:${toDegrees(pos[1])}, 纬度: ${toDegrees(pos[0])}, 高层: ${pos[2]}`
+  );
+  ElMessage.success("经纬度高层复制成功");
+};
+
+onMounted(() => {
+  if (!panoDomRef.value) throw "没有canvas DOM";
+  const canvas = panoDomRef.value;
+  canvas.width = canvas.offsetWidth;
+  canvas.height = canvas.offsetHeight;
+  const pano = init(canvas);
+
+  destroyFns.push(
+    watchEffect(() => {
+      if (panoUrls.value) {
+        loading.value = true;
+        pano.changeUrls(panoUrls.value).then(() => (loading.value = false));
+      }
+    }),
+    pano.destory
+  );
+});
+
+onUnmounted(() => mergeFuns(...destroyFns)());
+watchEffect(() => {
+  if (router.currentRoute.value.name === "pano" && point.value) {
+    setDocTitle(point.value.name);
+  }
+});
+</script>
+
+<style scoped lang="scss">
+.pano-layout,
+canvas {
+  width: 100%;
+  height: 100%;
+}
+
+.pano-layout {
+  position: relative;
+  .btns {
+    position: absolute;
+    left: 50%;
+    transform: translateX(-50%);
+    bottom: 40px;
+    z-index: 1;
+  }
+}
+</style>

+ 77 - 0
src/view/pano/setUniform.ts

@@ -0,0 +1,77 @@
+import { mat4, vec3 } from "gl-matrix";
+
+export type Uniforms = {
+  [key in string]:
+    | number
+    | number[]
+    | vec3[]
+    | number[][]
+    | Float32Array
+    | Float32Array[]
+    | Uniforms
+    | Uniforms[]
+    | mat4[];
+};
+
+const setUniform = (
+  gl: WebGL2RenderingContext,
+  program: WebGLProgram,
+  key: string,
+  valR: number | number[] | Float32Array
+) => {
+  const val = (
+    valR instanceof Float32Array || Array.isArray(valR) ? valR : [valR]
+  ) as number[];
+
+  const loc = gl.getUniformLocation(program, key);
+
+  if (loc) {
+    try {
+      if (/(mat$)|(mats\[\d+\])/gi.test(key) && (valR as any).length) {
+        (gl as any)[`uniformMatrix${Math.sqrt(val.length)}fv`](loc, false, val);
+      } else if (key.includes("Tex") || key.includes("tex")) {
+        gl.uniform1iv(loc, val);
+      } else if (val.length > 4) {
+        for (let i = 0; i < val.length; i++) {
+          setUniform(gl, program, `${key}[${i}]`, val[i]);
+        }
+      } else {
+        (gl as any)[`uniform${val.length}fv`](loc, val);
+      }
+    } catch (e) {
+      console.error(`key in ${key} val in`, val);
+      throw e;
+    }
+  } else {
+    // console.log(key, loc);
+  }
+};
+
+export const setUniforms = (
+  gl: WebGL2RenderingContext,
+  program: WebGLProgram,
+  data: Uniforms,
+  prefix = ""
+) => {
+  Object.entries(data).forEach(([k, v]) => {
+    if (
+      v instanceof Float32Array ||
+      Array.isArray(v) ||
+      typeof v !== "object"
+    ) {
+      if (Array.isArray(v) && typeof v[0] === "object") {
+        v.forEach((vi, ndx) => {
+          if (vi instanceof Float32Array) {
+            setUniform(gl, program, prefix + k + `[${ndx}]`, vi);
+          } else {
+            setUniforms(gl, program, vi as Uniforms, prefix + k + `[${ndx}].`);
+          }
+        });
+      } else {
+        setUniform(gl, program, prefix + k, v as number);
+      }
+    } else {
+      setUniforms(gl, program, v as Uniforms, k + ".");
+    }
+  });
+};

+ 14 - 0
src/view/pano/shader-env.frag

@@ -0,0 +1,14 @@
+#version 300 es
+precision highp float;
+
+uniform samplerCube envTex;
+
+in vec4 vLocalPosition;
+out vec4 oFragColor;
+
+void main(){
+  vec3 nor = normalize(vLocalPosition.xyz / vLocalPosition.w);
+  nor.x *= -1.0;
+  vec3 color = texture(envTex, nor).rgb;
+  oFragColor = vec4(color, 1);
+}

+ 11 - 0
src/view/pano/shader-env.vert

@@ -0,0 +1,11 @@
+#version 300 es
+layout(location = 0) in vec2 position;
+
+uniform mat4 invProjectionViewMat;
+
+out vec4 vLocalPosition;
+
+void main(){
+  gl_Position = vec4(position.xy, 1, 1.0);
+  vLocalPosition = invProjectionViewMat * gl_Position;
+}

+ 131 - 0
src/view/relics.vue

@@ -0,0 +1,131 @@
+<template>
+  <div class="relics-layout">
+    <div class="relics-header">
+      <div class="search">
+        <el-form label-width="60px" inline>
+          <el-form-item label="名称:">
+            <el-input v-model="pageProps.name" />
+          </el-form-item>
+          <el-form-item class="searh-btns">
+            <el-button type="primary" @click="refresh">查询</el-button>
+            <el-button type="primary" plain @click="pageProps = initProps">
+              重置
+            </el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+      <div class="relics-oper">
+        <el-button type="primary" @click="inputMode = true">新增</el-button>
+      </div>
+    </div>
+
+    <el-table :data="relicsArray" border>
+      <el-table-column prop="name" label="文物保护单位名称" />
+      <el-table-column label="操作" width="120">
+        <template #default="{ row }">
+          <el-button
+            link
+            type="primary"
+            size="small"
+            @click="router.push({ name: 'map', params: { relicsId: row.relicsId } })"
+          >
+            编辑
+          </el-button>
+          <el-button link type="danger" @click="delHandler(row.relicsId)" size="small">
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <div class="pag-layout">
+      <el-pagination
+        background
+        layout="prev, pager, next"
+        :total="total"
+        @current-change="(data: number) => pageProps.pageNum = data"
+        :current-page="pageProps.pageNum"
+        :page-size="pageProps.pageSize"
+      />
+    </div>
+  </div>
+
+  <SingleInput
+    :visible="inputMode"
+    @update:visible="inputMode = false"
+    :value="''"
+    :update-value="addRelicsHandler"
+    title="添加文物保护单位"
+  />
+</template>
+
+<script lang="ts" setup>
+import { onActivated, ref, watchEffect } from "vue";
+import SingleInput from "@/components/single-input.vue";
+import {
+  relicsPageFetch,
+  RelicsPageProps,
+  addRelicsFetch,
+  delRelicsFetch,
+} from "@/request";
+import { Relics } from "@/request/type";
+import { router } from "@/router";
+import { ElMessageBox } from "element-plus";
+
+const initProps: RelicsPageProps = {
+  name: "",
+  pageNum: 1,
+  pageSize: 12,
+};
+const pageProps = ref({ ...initProps });
+const total = ref<number>(0);
+const relicsArray = ref<Relics[]>([]);
+
+const refresh = async () => {
+  const data = await relicsPageFetch(pageProps.value);
+  total.value = data.total;
+  relicsArray.value = data.records;
+};
+const addRelicsHandler = async (name: string) => {
+  await addRelicsFetch(name);
+  await refresh();
+};
+
+const delHandler = async (relicsId: number) => {
+  const ok = await ElMessageBox.confirm("确定要删除吗", {
+    type: "warning",
+  });
+  if (ok) {
+    await delRelicsFetch(relicsId);
+    await refresh();
+  }
+};
+const inputMode = ref(false);
+
+watchEffect(refresh);
+onActivated(refresh);
+</script>
+
+<style scoped lang="scss">
+.relics-layout {
+  height: 100%;
+  overflow-y: auto;
+  padding: 30px;
+}
+.pag-layout {
+  margin-top: 20px;
+  display: flex;
+  justify-content: center;
+}
+
+.relics-header {
+  display: flex;
+  align-items: center;
+  .search {
+    flex: 1;
+  }
+  .relics-oper {
+    flex: 0 0 100px;
+    text-align: right;
+  }
+}
+</style>

+ 5 - 0
src/vite-env.d.ts

@@ -0,0 +1,5 @@
+/// <reference types="vite/client" />
+
+import "element-plus/global.d.ts";
+
+declare module "element-plus/dist/locale/zh-cn.mjs";

+ 41 - 0
tsconfig.json

@@ -0,0 +1,41 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": [
+      "ES2020",
+      "DOM",
+      "DOM.Iterable"
+    ],
+    "skipLibCheck": true,
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "preserve",
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "baseUrl": "./",
+    "paths": {
+      "@/*": [
+        "src/*"
+      ]
+    }
+  },
+  "include": [
+    "src/**/*.ts",
+    "src/**/*.tsx",
+    "src/**/*.vue"
+  ],
+  "references": [
+    {
+      "path": "./tsconfig.node.json"
+    }
+  ]
+}

+ 13 - 0
tsconfig.node.json

@@ -0,0 +1,13 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "skipLibCheck": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true,
+    "strict": true
+  },
+  "include": [
+    "vite.config.ts"
+  ]
+}

+ 34 - 0
vite.config.ts

@@ -0,0 +1,34 @@
+import { defineConfig } from "vite";
+import { resolve } from "path";
+import vue from "@vitejs/plugin-vue";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  base: "./",
+  plugins: [vue()],
+  resolve: {
+    alias: [
+      {
+        find: "@",
+        replacement: resolve(__dirname, "./src"),
+      },
+    ],
+  },
+  server: {
+    host: "0.0.0.0",
+    port: 5173,
+    open: true,
+    proxy: {
+      // "/relics": {
+      //   target: "http://192.168.0.11:8324",
+      //   changeOrigin: true,
+      //   rewrite: (path) => path.replace(/^\/relics/, "/relics"),
+      // },
+      "/api": {
+        target: `https://test-sp.4dkankan.com/`,
+        changeOrigin: true,
+        rewrite: (path) => path.replace(/^\/api/, "/api"),
+      },
+    },
+  },
+});