Browse Source

制作v1.7.0功能

bill 11 tháng trước cách đây
mục cha
commit
54e6f57d35

+ 4 - 4
src/app/criminal/store/example.ts

@@ -17,8 +17,8 @@ export const getExamplePagging = async (
 export const delExample = (example: Example) =>
   axios.post(deleteExample, { caseId: example.caseId });
 
-export const addExample = (caseTitle: string) =>
-  axios.post(setExampleUrl, { caseTitle });
+export const addExample = (example: Omit<Example, "id">) =>
+  axios.post(setExampleUrl, example);
 
-export const setExample = (caseTitle: string, id: number) =>
-  axios.post(setExampleUrl, { caseTitle, caseId: id });
+export const setExample = (example: Example) =>
+  axios.post(setExampleUrl, example);

+ 26 - 2
src/app/criminal/view/example/edit.vue

@@ -7,6 +7,18 @@
         placeholder="请输入案件名称"
       />
     </el-form-item>
+    <el-form-item label="详细地址">
+      <el-input
+        v-model="bindExample.mapUrl"
+        placeholder="输入名称搜索"
+        clearable
+        disabled
+      >
+        <template #append>
+          <el-button :icon="Search" @click="searchAMapAddress" />
+        </template>
+      </el-input>
+    </el-form-item>
   </el-form>
 </template>
 
@@ -15,6 +27,8 @@ import { ref } from "vue";
 import { Example, setExample, addExample } from "@/app/criminal/store/example";
 import { ElMessage } from "element-plus";
 import { QuiskExpose } from "@/helper/mount";
+import { Search } from "@element-plus/icons-vue";
+import { selectMapImage } from "@/view/case/quisk";
 
 const props = defineProps<{ example?: Example }>();
 const bindExample = ref<Example>(props.example ? { ...props.example } : ({} as Example));
@@ -24,10 +38,20 @@ defineExpose<QuiskExpose>({
     if (!bindExample.value.caseTitle || !bindExample.value.caseTitle.trim()) {
       ElMessage.error("案件名称不能为空");
       throw "案件名称不能为空";
+    } else if (!bindExample.value.latAndLong || !bindExample.value.latAndLong.trim()) {
+      ElMessage.error("详细地址不能为空");
+      throw "详细地址不能为空!";
     }
     await (bindExample.value.caseId
-      ? setExample(bindExample.value.caseTitle, bindExample.value.caseId)
-      : addExample(bindExample.value.caseTitle));
+      ? setExample(bindExample.value)
+      : addExample(bindExample.value));
   },
 });
+
+const searchAMapAddress = async () => {
+  const data = await selectMapImage({});
+  if (!data?.search) return;
+  bindExample.value.mapUrl = data.search.text;
+  bindExample.value.latAndLong = `${data.search.lat},${data.search.lng}`;
+};
 </script>

+ 2 - 0
src/app/fire/routeConfig.ts

@@ -14,6 +14,8 @@ export const menuRouteNames = [
   FireRouteName.dispatch,
   FireRouteName.teaching,
   FireRouteName.organization,
+  FireRouteName.downloadLog,
+  FireRouteName.statistics,
   FireRouteName.role,
   FireRouteName.user,
 ];

+ 7 - 11
src/app/fire/store/fire.ts

@@ -39,6 +39,8 @@ export type Fire = {
   projectName: string;
   projectSite: string;
   projectSiteCode: string;
+  mapUrl: string;
+  latAndLong: string;
   projectSn: string;
   status: FireStatus;
   statusDesc: string;
@@ -55,8 +57,7 @@ type FirePaggingParams = PaggingReq<Fire & { queryType: FirePaggingRoute }>;
 export const getFirePagging = async (params: FirePaggingParams) =>
   (await axios.get(getFireList, { params })).data as PaggingRes<Fire>;
 
-export const addFire = async (fire: Omit<Fire, "id">) =>
-  axios.post(insertFire, fire);
+export const addFire = async (fire: Omit<Fire, "id">) => axios.post(insertFire, fire);
 
 export const setFire = async (fire: Fire) => await axios.post(updateFire, fire);
 
@@ -78,13 +79,8 @@ export type FireLeaveMsg = {
   createTime: number;
 };
 
-export const getFireLeaveMsgPagging = async (
-  params: PaggingReq<{ projectId: string }>
-) =>
-  (await axios.get(getMessageList, { params }))
-    .data as PaggingRes<FireLeaveMsg>;
+export const getFireLeaveMsgPagging = async (params: PaggingReq<{ projectId: string }>) =>
+  (await axios.get(getMessageList, { params })).data as PaggingRes<FireLeaveMsg>;
 
-export const addFireLeaveMsg = (params: {
-  content: string;
-  projectId: string;
-}) => axios.post(insertMessage, params);
+export const addFireLeaveMsg = (params: { content: string; projectId: string }) =>
+  axios.post(insertMessage, params);

+ 27 - 1
src/app/fire/view/dispatch/editFire.vue

@@ -20,6 +20,20 @@
         </el-form-item>
       </el-col>
     </div>
+    <el-form-item label="详细地址">
+      <el-input
+        v-model="bindFire.mapUrl"
+        placeholder="输入名称搜索"
+        clearable
+        disabled
+        class="mandatory"
+      >
+        <template #append>
+          <el-button :icon="Search" @click="searchAMapAddress" />
+        </template>
+      </el-input>
+    </el-form-item>
+
     <el-form-item label="起火地址" class="mandatory">
       <el-input
         v-model="bindFire.projectAddress"
@@ -84,6 +98,8 @@ import { dateFormat } from "@/util";
 import { genCascaderValue, getCode } from "@/helper/cascader";
 import { QuiskExpose } from "@/helper/mount";
 import { user } from "@/store/user";
+import { Search } from "@element-plus/icons-vue";
+import { selectMapImage } from "@/view/case/quisk";
 
 const props = defineProps<{ fire?: Fire }>();
 
@@ -103,7 +119,10 @@ const accidentDate = ref(
 
 defineExpose<QuiskExpose>({
   async submit() {
-    if (!bindFire.value.projectAddress || !bindFire.value.projectAddress.trim()) {
+    if (!bindFire.value.latAndLong || !bindFire.value.latAndLong.trim()) {
+      ElMessage.error("详细地址不能为空");
+      throw "详细地址不能为空!";
+    } else if (!bindFire.value.projectAddress || !bindFire.value.projectAddress.trim()) {
       ElMessage.error("起火地址不能为空!");
       throw "起火地址不能为空!";
     } else if (!bindFire.value.projectSn || !bindFire.value.projectSn.trim()) {
@@ -136,4 +155,11 @@ defineExpose<QuiskExpose>({
       : await addFire(bindFire.value as any);
   },
 });
+
+const searchAMapAddress = async () => {
+  const data = await selectMapImage({});
+  if (!data?.search) return;
+  bindFire.value.mapUrl = data.search.text;
+  bindFire.value.latAndLong = `${data.search.lat},${data.search.lng}`;
+};
 </script>

+ 25 - 2
src/app/ga/view/example/edit.vue

@@ -7,6 +7,18 @@
         placeholder="请输入案件名称"
       />
     </el-form-item>
+    <el-form-item label="详细地址">
+      <el-input
+        v-model="bindExample.mapUrl"
+        placeholder="输入名称搜索"
+        clearable
+        disabled
+      >
+        <template #append>
+          <el-button :icon="Search" @click="searchAMapAddress" />
+        </template>
+      </el-input>
+    </el-form-item>
   </el-form>
 </template>
 
@@ -15,6 +27,8 @@ import { ref } from "vue";
 import { Example, setExample, addExample } from "@/app/criminal/store/example";
 import { ElMessage } from "element-plus";
 import { QuiskExpose } from "@/helper/mount";
+import { Search } from "@element-plus/icons-vue";
+import { selectMapImage } from "@/view/case/quisk";
 
 const props = defineProps<{ example?: Example }>();
 const bindExample = ref<Example>(props.example ? { ...props.example } : ({} as Example));
@@ -24,10 +38,19 @@ defineExpose<QuiskExpose>({
     if (!bindExample.value.caseTitle || !bindExample.value.caseTitle.trim()) {
       ElMessage.error("案件名称不能为空");
       throw "案件名称不能为空";
+    } else if (!bindExample.value.latAndLong || !bindExample.value.latAndLong.trim()) {
+      ElMessage.error("详细地址不能为空");
+      throw "详细地址不能为空!";
     }
     await (bindExample.value.caseId
-      ? setExample(bindExample.value.caseTitle, bindExample.value.caseId)
-      : addExample(bindExample.value.caseTitle));
+      ? setExample(bindExample.value)
+      : addExample(bindExample.value));
   },
 });
+const searchAMapAddress = async () => {
+  const data = await selectMapImage({});
+  if (!data?.search) return;
+  bindExample.value.mapUrl = data.search.text;
+  bindExample.value.latAndLong = `${data.search.lat},${data.search.lng}`;
+};
 </script>

+ 7 - 1
src/components/dialog/index.vue

@@ -24,7 +24,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref, watch, watchEffect } from "vue";
+import { computed, ref, watch, watchEffect, nextTick } from "vue";
 import { user } from "@/store/user";
 import { operateIsPermissionByPath } from "@/directive/permission";
 import { DialogProps } from "./type";
@@ -34,9 +34,11 @@ const props = withDefaults(defineProps<DialogProps>(), {
   show: false,
   hideFloor: false,
   showClose: true,
+  notSubmit: false,
   enterText: "保 存",
 });
 
+
 const emit = defineEmits<{
   (e: "update:show", show: boolean): void;
   (e: "quit"): void;
@@ -58,6 +60,10 @@ const deleteHandle = () => {
   emit("delete");
 };
 
+if (!showFloor.value && !props.notSubmit) {
+  nextTick(() => enterHandle());
+}
+
 const disabled = computed(() => props.power && !operateIsPermissionByPath(props.power));
 </script>
 

+ 14 - 0
src/components/dialog/type.ts

@@ -2,6 +2,7 @@ export type DialogProps = {
   show?: boolean;
   title: string;
   hideFloor?: boolean;
+  notSubmit?: boolean,
   enterText?: string;
   width?: number;
   power?: string;
@@ -9,3 +10,16 @@ export type DialogProps = {
   showDelete?: boolean;
   cornerClose?: boolean;
 };
+
+export const dialogPropsKeys: (keyof DialogProps)[] = [
+  "show",
+  "title",
+  "hideFloor",
+  "notSubmit",
+  "enterText",
+  "width",
+  "power",
+  "showClose",
+  "showDelete",
+  "cornerClose",
+];

+ 2 - 1
src/request/config.ts

@@ -4,6 +4,7 @@ import {
   getAttachListByPsw,
   getCode,
   getCompanyList,
+  getDownloadProcess,
   getFireList,
   getMessageList,
   getModelSceneList,
@@ -62,4 +63,4 @@ export const successCode = [0, "000000", 200];
 // baseURL
 export const baseURL = import.meta.env.DEV ? "/api" : "";
 
-export const notOpenUrls: string[] = [uploadModel];
+export const notOpenUrls: string[] = [uploadModel, getDownloadProcess];

+ 12 - 3
src/request/urls.ts

@@ -77,6 +77,14 @@ export const deleteModel = `/fusion/model/delete`;
 export const uploadModel = `/fusion/model/uploadObj`;
 export const getModelRunProgress = `/fusion/model/uploadObjProgress`;
 
+// 统计
+export const sceneStatistics = `/fusion/data/sceneGroupByDept`;
+export const caseStatistics = `/fusion/data/projectGroupByDept`;
+export const cameraTypeStatistics = `/fusion/data/cameraGroupType`;
+export const caseTimeStatistics = `/fusion/data/FireTrend`;
+export const casePlaceStatistics = `/fusion/data/FirePlaceTrend`;
+export const caseReasonStatistics = `/fusion/data/FireReasonTrend`;
+
 // 通过场景标题模糊搜索场景
 export const getSceneByTitle = "/web/scene/getLocalScenes";
 // 通过相机获取场景列表
@@ -197,11 +205,12 @@ export const uploadAttachImage = "/web/fireProject/uploadImage";
 /** ------------------------------------------ */
 
 // 下载校验
-export const checkHasDownload = "/web/scene/checkDownload/";
+export const checkHasDownload = "/fusion/sceneDownLog/checkDownLoad";
 // 下载获取进度条
-export const getDownloadProcess = "/web/scene/downloadProcess";
+export const getDownloadProcess = "/fusion/sceneDownLog/downloadProcess";
 // 下载
-export const downloadScene = "/web/scene/downloadScene/";
+export const downloadScene = "/fusion/sceneDownLog/downScene";
+export const downloadSceneList = "/fusion/sceneDownLog/list";
 // 带看相关接口
 export const offLine = "/web/fireProject/offLine"; //{roomId}
 export const onLine = "/web/fireProject/onLine"; //{roomId}

+ 12 - 0
src/router/config.ts

@@ -49,6 +49,12 @@ export const routes: Routes = [
         meta: { title: "首页", icon: "iconfire_home" },
       },
       {
+        name: RouteName.downloadLog,
+        path: "download-log",
+        component: () => import("@/view/vrmodel/downloadLog.vue"),
+        meta: { title: "下载记录", icon: "iconfire_camera" },
+      },
+      {
         name: RouteName.vrmodel,
         path: "vrmodel",
         component: () => import("@/view/vrmodel/index.vue"),
@@ -85,6 +91,12 @@ export const routes: Routes = [
         component: () => import("@/view/case/caseFile.vue"),
         meta: { title: "卷宗管理" },
       },
+      {
+        name: RouteName.statistics,
+        path: "/statistics",
+        component: () => import("@/view/statistics/index.vue"),
+        meta: { title: "数据统计", icon: "iconfire_user" },
+      },
     ],
   },
   {

+ 2 - 0
src/router/routeName.ts

@@ -1,6 +1,8 @@
 export const RouteName = {
+  downloadLog: "downloadLog",
   login: "login",
   register: "register",
+  statistics: "statistics",
   forget: "forget",
   viewLayout: "viewLayout",
   home: "home",

+ 2 - 0
src/store/case.ts

@@ -14,6 +14,8 @@ import { CaseFile } from "./caseFile";
 export type Case = {
   caseId: number;
   caseTitle: string;
+  mapUrl: string;
+  latAndLong: string;
   createTime: string;
   name: string;
   tbStatus: string;

+ 32 - 0
src/store/scene.ts

@@ -7,6 +7,7 @@ import {
   checkGenMeshScene,
   delScene,
   deleteModel,
+  downloadSceneList,
   genMeshSceneByCloud,
   getModelRunProgress,
   getModelSceneList,
@@ -157,6 +158,37 @@ export const getScenePagging = async (params: ScenePaggingParams) => {
 export const delQuoteScene = (scene: QuoteScene) =>
   axios.get(delScene, { params: { num: scene.num } });
 
+export type QueryDownloadQuoteSceneParams = PaggingReq<{
+  deptId: string;
+  userName: string;
+  nickName: string;
+  createTime: string;
+  sceneTitle: string;
+  sceneNum: string;
+  snCode: string;
+}>;
+
+export type DownloadQuoteSceneLog = {
+  id: number;
+  sceneNum: string;
+  sceneTitle: string;
+  userName: string;
+  nickName: string;
+  snCode: string;
+  deptLevelStr: string;
+  deptName: string;
+  deptId: string;
+  createTime: string;
+};
+export const getDownloadQuoteScene = async (
+  params: Omit<QueryDownloadQuoteSceneParams, "createTime"> & {
+    startCreateTime: string | null;
+    endCreateTime: string | null;
+  }
+) =>
+  (await axios.post(downloadSceneList, params))
+    .data as PaggingRes<DownloadQuoteSceneLog>;
+
 export const genMeshScene = async (scene: QuoteScene) => {
   const res = (await axios.post(checkGenMeshScene, { id: scene.id })).data;
   if (res?.buildObjStatus === 2) {

+ 53 - 0
src/store/statistics.ts

@@ -0,0 +1,53 @@
+import { place } from "@/app/fire/constant/fire";
+import {
+  axios,
+  cameraTypeStatistics,
+  casePlaceStatistics,
+  caseReasonStatistics,
+  caseStatistics,
+  caseTimeStatistics,
+  sceneStatistics,
+} from "@/request";
+
+export type StatisticsParams = {
+  startTime: string;
+  endTime: string;
+};
+export type StatisticsItem = { groupKey: string; dataCount: number };
+export type StatisticsItems = StatisticsItem[];
+
+const getAmount = (sItem: StatisticsItem, options: string[]) =>
+  options.length - options.findIndex((item) => item === sItem.groupKey);
+
+export const getSceneStatistics = async (params: StatisticsParams) =>
+  (await axios.post<StatisticsItems>(sceneStatistics, params)).data;
+
+export const getCaseStatistics = async (params: StatisticsParams) =>
+  (await axios.post<StatisticsItems>(caseStatistics, params)).data;
+
+const types = ["八目", "双目转台", "激光转台", "激光移动"];
+export const getCameraTypeStatistics = async (params: StatisticsParams) => {
+  const typeItems = (
+    await axios.post<StatisticsItems>(cameraTypeStatistics, params)
+  ).data;
+  typeItems.sort((a, b) => getAmount(b, types) - getAmount(a, types));
+  console.log(typeItems);
+  return typeItems;
+};
+
+export const getCaseTimeStatistics = async (params: StatisticsParams) =>
+  (await axios.post<StatisticsItems>(caseTimeStatistics, params)).data;
+
+const placeLabels = place.map((item) => item.label);
+export const getCasePlaceStatistics = async (params: StatisticsParams) => {
+  const placeItems = (
+    await axios.post<StatisticsItems>(casePlaceStatistics, params)
+  ).data;
+  placeItems.sort(
+    (a, b) => getAmount(b, placeLabels) - getAmount(a, placeLabels)
+  );
+  return placeItems;
+};
+
+export const getCaseReasonStatistics = async (params: StatisticsParams) =>
+  (await axios.post<StatisticsItems>(caseReasonStatistics, params)).data;

+ 1 - 1
src/view/case/addScenes.vue

@@ -52,7 +52,7 @@ watch(
   () => params.pagging.state.query,
   () => {
     params.pagging.state.query.status = 2;
-    params.pagging.state.query.caseId = props.caseId;
+    // params.pagging.state.query.caseId = props.caseId;
   },
   { immediate: true, deep: true }
 );

+ 78 - 15
src/view/case/draw/selectMapImage.vue

@@ -5,12 +5,22 @@
         <el-button :icon="Search" />
       </template>
     </el-input>
-    <div class="search-result" v-show="keyword" ref="resultEl"></div>
+    <div class="rrr">
+      <div class="search-result" v-show="keyword && showSearch" ref="resultEl"></div>
+      <div class="search-sh" v-show="keyword">
+        <el-button style="width: 100%" @click="showSearch = !showSearch">
+          {{ showSearch ? "收起" : "展开" }}搜索结果
+        </el-button>
+      </div>
+    </div>
   </div>
-  <div class="def-select-map" ref="mapEl"></div>
+  <div class="def-select-map-layout">
+    <div class="def-select-map" ref="mapEl"></div>
+  </div>
+
   <div class="def-map-info" v-if="info">
-    <p><span>经度</span>{{ info.lat }}</p>
-    <p><span>维度</span>{{ info.lng }}</p>
+    <p><span>度</span>{{ info.lat }}</p>
+    <p><span>度</span>{{ info.lng }}</p>
     <p><span>缩放级别</span>{{ info.zoom }}</p>
   </div>
 </template>
@@ -22,11 +32,19 @@ import { ref, watchEffect } from "vue";
 import { QuiskExpose } from "@/helper/mount";
 import { debounce } from "@/util";
 
-export type MapImage = { blob: Blob | null };
-type MapInfo = { lat: number; lng: number; zoom: number };
+export type MapImage = { blob: Blob | null; search: MapInfo | null };
+type MapInfo = { lat: number; lng: number; zoom: number; text: string };
 
 const keyword = ref("");
+const showSearch = ref(true);
 const info = ref<MapInfo>();
+const searchInfo = ref<MapInfo>();
+
+watchEffect(() => {
+  if (keyword.value) {
+    showSearch.value = true;
+  }
+});
 
 const mapEl = ref<HTMLDivElement>();
 const resultEl = ref<HTMLDivElement>();
@@ -37,7 +55,7 @@ watchEffect(async (onCleanup) => {
     return;
   }
   const AMap = await AMapLoader.load({
-    plugins: ["AMap.PlaceSearch"],
+    plugins: ["AMap.PlaceSearch", "AMap.Event"],
     key: "e661b00bdf2c44cccf71ef6070ef41b8",
     version: "2.0",
   });
@@ -50,23 +68,54 @@ watchEffect(async (onCleanup) => {
   });
   const placeSearch = new AMap.PlaceSearch({
     pageSize: 5,
+    showCover: false,
     pageIndex: 1,
     map: map,
     panel: resultEl.value,
     autoFitView: true,
   });
+  const setSearch = (data) => {
+    searchInfo.value = {
+      text: data.pname + data.cityname + data.adname + data.address,
+      lat: data.location.lat,
+      lng: data.location.lng,
+      zoom: 0,
+    };
+  };
+
+  placeSearch.on("listElementClick", (e) => {
+    setSearch(e.data);
+    showSearch.value = false;
+  });
+
+  placeSearch.on("complete", function (result) {
+    setTimeout(() => {
+      const markers = map.getAllOverlays("marker");
+      for (const marker of markers) {
+        marker.on("click", () => {
+          setSearch(marker._data);
+        });
+      }
+    }, 500);
+  });
+
   const getMapInfo = (): MapInfo => {
     var zoom = map.getZoom(); //获取当前地图级别
     var center = map.getCenter();
     return {
+      text: "",
       zoom,
       lat: center.lat,
       lng: center.lng,
     };
   };
   //绑定地图移动与缩放事件
-  map.on("moveend", () => (info.value = getMapInfo()));
-  map.on("zoomend", () => (info.value = getMapInfo()));
+  map.on("moveend", () => {
+    info.value = getMapInfo();
+  });
+  map.on("zoomend", () => {
+    info.value = getMapInfo();
+  });
   searchAMap.value = placeSearch;
 
   onCleanup(() => {
@@ -87,9 +136,9 @@ defineExpose<QuiskExpose>({
     return new Promise<MapImage>((resolve) => {
       if (mapEl.value) {
         const canvas = mapEl.value.querySelector("canvas") as HTMLCanvasElement;
-        canvas.toBlob((blob) => resolve({ blob }));
+        canvas.toBlob((blob) => resolve({ blob, search: searchInfo.value! }));
       } else {
-        resolve({ blob: null });
+        resolve({ blob: null, search: null });
       }
     });
   },
@@ -104,11 +153,15 @@ defineExpose<QuiskExpose>({
   z-index: 2;
 }
 
-.search-result {
+.rrr {
   position: absolute;
   left: 0;
   right: 0;
   z-index: 1;
+}
+
+.search-sh,
+.search-result {
   overflow: hidden;
 
   &.show {
@@ -134,9 +187,19 @@ defineExpose<QuiskExpose>({
   }
 }
 
-.def-select-map {
-  width: 540px;
-  height: 390px;
+.def-select-map-layout {
+  --scale: 1.5;
+  width: 100%;
+  padding-top: calc((390 / 540) * 100%);
+  position: relative;
   z-index: 1;
 }
+
+.def-select-map {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+}
 </style>

+ 2 - 1
src/view/case/quisk.ts

@@ -34,6 +34,7 @@ export const editEshapeTable = (
 export const showCaseScenes = quiskMountFactory(SceneList, {
   title: "案件场景管理",
   width: 900,
+  notSubmit: true,
   hideFloor: true,
 });
 
@@ -44,7 +45,7 @@ export const selectFuseImage = quiskMountFactory(SelectFuseImage, {
 
 export const selectMapImage = quiskMountFactory(SelectMapImage, {
   title: "选择地址",
-  width: 588,
+  width: 800,
 })<MapImage>;
 
 export const shareCase = quiskMountFactory(ShareCase, {

+ 171 - 0
src/view/statistics/index.vue

@@ -0,0 +1,171 @@
+<template>
+  <com-head :options="[{ name: '数据统计', value: '1' }]" class="static-head frame-head">
+    <el-form label-width="84px">
+      <el-form-item label="统计区间:" style="grid-area: 1/1/2/2">
+        <p style="margin-top: -6px">{{ range }}</p>
+      </el-form-item>
+      <el-form-item label="统计区间:" style="grid-area: 1/2/2/4">
+        <el-date-picker
+          type="daterange"
+          v-model="params"
+          range-separator="-"
+          placeholder="请选择"
+          style="width: 400px"
+        />
+      </el-form-item>
+    </el-form>
+  </com-head>
+  <div class="statistics-layer">
+    <div v-for="(config, ndx) in statisticsConfigs" class="statistics-item">
+      <div class="statistics-content">
+        <p class="title">{{ config.title }}</p>
+        <div class="graphics" :ref="(dom: any) => updateDom(ndx, dom)"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import comHead from "@/components/head/index.vue";
+import {
+  markRaw,
+  nextTick,
+  onMounted,
+  onUnmounted,
+  reactive,
+  ref,
+  watch,
+  watchEffect,
+} from "vue";
+import {
+  statisticsConfigs,
+  echarts,
+  EChartsType,
+  ConfigItem,
+  updateParams,
+} from "./statisticsInject";
+import { dateFormat } from "@/util";
+import { user } from "@/store/user";
+
+const prevYearDate = new Date();
+prevYearDate.setFullYear(prevYearDate.getFullYear() - 1);
+const params = ref<Date[]>([prevYearDate, new Date()]);
+const range = [
+  "全部火调队伍数据",
+  "总队及下级队伍数据",
+  "支队及下级队伍数据",
+  "当前队伍数据",
+][user.value.info.deptLevel];
+
+watchEffect(() => {
+  updateParams({
+    startTime: dateFormat(params.value[0], "yyyy-MM-dd"),
+    endTime: dateFormat(params.value[1], "yyyy-MM-dd"),
+  });
+});
+
+const doms = statisticsConfigs.map(() => ref<HTMLDivElement>());
+const updateDom = (ndx: number, dom: HTMLDivElement) => {
+  nextTick(() => (doms[ndx].value = dom));
+};
+
+const charts = reactive([]) as (EChartsType | null)[];
+doms.forEach((dom, ndx) => {
+  watchEffect((onCleanup) => {
+    if (dom.value) {
+      const chart = echarts.init(dom.value);
+      markRaw(chart);
+      charts[ndx] = chart;
+      onCleanup(() => {
+        echarts.dispose(charts[ndx]!);
+        charts[ndx] = null;
+      });
+    }
+  });
+});
+
+type WData = [ConfigItem[], (EChartsType | null)[]];
+watch(
+  () => [statisticsConfigs, charts] as WData,
+  (nInstalls) => {
+    const [nConfigs, nCharts] = nInstalls;
+    for (let ndx = 0; ndx < nCharts.length; ndx++) {
+      if (!nCharts[ndx]) continue;
+      nCharts[ndx]!.setOption(nConfigs[ndx].data);
+    }
+  },
+  { deep: true }
+);
+
+const resize = () => {
+  for (const chart of charts) {
+    chart?.resize();
+  }
+};
+
+onMounted(() => window.addEventListener("resize", resize));
+onUnmounted(() => window.removeEventListener("resize", resize));
+</script>
+
+<style lang="scss" scoped>
+.statistics-layer {
+  flex: 1;
+  margin-top: 16px;
+  overflow-y: auto;
+}
+
+.statistics-item {
+  float: left;
+  width: calc(50% - 8px);
+  padding-top: 35%;
+  position: relative;
+  margin-bottom: 16px;
+
+  .statistics-content {
+    background-color: #fff;
+    position: absolute;
+    left: 0;
+    top: 0;
+    right: 0;
+    bottom: 0;
+  }
+
+  &:nth-child(2n - 1) {
+    margin-right: 8px;
+  }
+  &:nth-child(2n) {
+    margin-left: 8px;
+  }
+}
+
+.statistics-content {
+  display: flex;
+  flex-direction: column;
+
+  .title {
+    flex: 0 0 auto;
+    line-height: 1;
+    padding: 16px;
+    border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+    font-size: 16px;
+  }
+
+  .graphics {
+    flex: 1;
+    padding: 15px;
+  }
+}
+</style>
+
+<style lang="scss">
+.static-head {
+  .el-range-separator {
+    display: initial !important;
+    line-height: 38px;
+    font-size: 16px;
+  }
+  .el-date-editor--daterange::after {
+    display: none;
+  }
+}
+</style>

+ 273 - 0
src/view/statistics/statisticsInject.ts

@@ -0,0 +1,273 @@
+import * as uecharts from "echarts";
+import { reactive } from "vue";
+import {
+  StatisticsParams,
+  getSceneStatistics,
+  getCaseStatistics,
+  getCameraTypeStatistics,
+  getCaseTimeStatistics,
+  getCasePlaceStatistics,
+  getCaseReasonStatistics,
+} from "@/store/statistics";
+
+export const echarts = uecharts;
+export type { EChartsType } from "echarts";
+
+export type ConfigItem = {
+  title: string;
+  data: any;
+};
+
+export const statisticsConfigs: ConfigItem[] = reactive([
+  {
+    title: "火灾场景数据采集统计",
+    data: {
+      tooltip: {
+        trigger: "item",
+      },
+      legend: {
+        orient: "vertical",
+        left: "right",
+        type: "scroll",
+      },
+      series: [
+        {
+          itemStyle: {
+            borderColor: "#fff",
+            borderWidth: 3,
+          },
+          name: "火灾场景数据采集统计",
+          type: "pie",
+          label: {
+            formatter: "{c}个",
+            rich: {
+              time: {
+                fontSize: 10,
+                color: "#999",
+              },
+            },
+          },
+          radius: "50%",
+          center: ["40%", "50%"],
+          data: [],
+        },
+      ],
+    },
+  },
+  {
+    title: "采集设备类型统计",
+    data: {
+      tooltip: {
+        trigger: "axis",
+      },
+      xAxis: {
+        type: "category",
+        data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
+      },
+      yAxis: {
+        type: "value",
+      },
+      series: [
+        {
+          label: {
+            show: true,
+          },
+          data: [120, 200, 150, 80, 70, 110, 130],
+          type: "bar",
+        },
+      ],
+    },
+  },
+  {
+    title: "火调项目统计",
+    data: {
+      tooltip: {
+        trigger: "item",
+      },
+      legend: {
+        orient: "vertical",
+        left: "right",
+        type: "scroll",
+        // top: "center",
+      },
+      series: [
+        {
+          itemStyle: {
+            borderColor: "#fff",
+            borderWidth: 3,
+          },
+          name: "火调项目统计",
+          type: "pie",
+          label: {
+            formatter: "{c}个",
+            rich: {
+              time: {
+                fontSize: 10,
+                color: "#999",
+              },
+            },
+          },
+          radius: "50%",
+          center: ["40%", "50%"],
+          data: [],
+        },
+      ],
+    },
+  },
+  {
+    title: "火灾趋势分析",
+    data: {
+      tooltip: {
+        trigger: "axis",
+      },
+      xAxis: {
+        type: "category",
+        data: [],
+      },
+      yAxis: {
+        type: "value",
+      },
+      series: [
+        {
+          label: {
+            show: true,
+          },
+          data: [],
+          type: "line",
+        },
+      ],
+    },
+  },
+  {
+    title: "起火场所",
+    data: {
+      tooltip: {
+        trigger: "axis",
+      },
+      xAxis: {
+        type: "category",
+        data: [],
+      },
+      yAxis: {
+        type: "value",
+      },
+      series: [
+        {
+          label: {
+            show: true,
+          },
+          data: [],
+          type: "bar",
+        },
+      ],
+    },
+  },
+  {
+    title: "火灾原因统计",
+    data: {
+      tooltip: {
+        trigger: "axis",
+      },
+      xAxis: {
+        type: "category",
+        data: [],
+      },
+      yAxis: {
+        type: "value",
+      },
+      series: [
+        {
+          label: {
+            show: true,
+          },
+          data: [],
+          type: "bar",
+        },
+      ],
+    },
+  },
+]);
+
+const numRotate = 8;
+const numSlide = 20;
+export const updateParams = async (params: StatisticsParams) => {
+  const statisticsItemsArray = await Promise.all([
+    getSceneStatistics(params),
+    getCameraTypeStatistics(params),
+    getCaseStatistics(params),
+    getCaseTimeStatistics(params),
+    getCasePlaceStatistics(params),
+    getCaseReasonStatistics(params),
+  ]);
+
+  const radiusNds = [0, 2];
+
+  for (let ndx = 0; ndx < statisticsConfigs.length; ndx++) {
+    let items = statisticsItemsArray[ndx];
+
+    if (radiusNds.includes(ndx)) {
+      statisticsConfigs[ndx].data.series![0].data = items.map((item) => ({
+        value: item.dataCount,
+        name: item.groupKey,
+      }));
+      console.log(statisticsConfigs[ndx].data);
+    } else {
+      statisticsConfigs[ndx].data.xAxis.data = items.map(
+        (item) => item.groupKey
+      );
+      statisticsConfigs[ndx].data.xAxis.axisLabel = {
+        interval: "auto",
+        rotate: 0,
+      };
+      statisticsConfigs[ndx].data.dataZoom = [
+        {
+          id: 1,
+          type: "inside",
+          start: 0,
+          end: 100,
+          disabled: true,
+          zoomLock: true,
+          show: false,
+        },
+        {
+          id: 2,
+          start: 0,
+          end: 100,
+          disabled: true,
+          zoomLock: true,
+          show: false,
+        },
+      ];
+
+      if (items.length > numRotate && items.length < numSlide) {
+        statisticsConfigs[ndx].data.xAxis.axisLabel = {
+          interval: 0,
+          rotate: 30,
+        };
+      } else if (items.length >= numSlide) {
+        statisticsConfigs[ndx].data.dataZoom = [
+          {
+            id: 1,
+            type: "inside",
+            start: 0,
+            end: 100,
+            disabled: false,
+            zoomLock: false,
+            show: true,
+          },
+          {
+            id: 2,
+            start: 0,
+            end: 100,
+            disabled: false,
+            zoomLock: false,
+            show: true,
+          },
+        ];
+      }
+      statisticsConfigs[ndx].data.series![0].data = items.map(
+        (item) => item.dataCount
+      );
+    }
+  }
+};

+ 123 - 0
src/view/vrmodel/downloadLog.vue

@@ -0,0 +1,123 @@
+<template>
+  <com-head :options="[{ name: '下载记录', value: '2' }]" showCtrl>
+    <el-form label-width="84px">
+      <el-form-item label="所属架构:">
+        <com-company v-model="state.query.deptId" />
+      </el-form-item>
+      <el-form-item label="用户姓名:">
+        <el-input v-model="state.query.nickName" placeholder="请输入"></el-input>
+      </el-form-item>
+      <el-form-item label="用户账号:">
+        <el-input v-model="state.query.userName" placeholder="请输入手机号"></el-input>
+      </el-form-item>
+      <el-form-item label="下载时间:">
+        <el-date-picker
+          type="daterange"
+          format="YYYY-MM-DD"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          v-model="createTime"
+          placeholder="请选择"
+          :defaultTime="defaultTime"
+          style="width: 100%"
+        />
+      </el-form-item>
+      <el-form-item label="场景标题:">
+        <el-input v-model="state.query.sceneTitle" placeholder="请输入"></el-input>
+      </el-form-item>
+      <el-form-item label="场景码:">
+        <el-input v-model="state.query.sceneNum" placeholder="请输入"></el-input>
+      </el-form-item>
+      <el-form-item label="SN码:">
+        <el-input v-model="state.query.snCode" placeholder="请输入"></el-input>
+      </el-form-item>
+
+      <el-form-item class="searh-btns" style="grid-area: 1/4/4/5">
+        <el-button type="primary" @click="refresh">查询</el-button>
+        <el-button type="primary" plain @click="queryReset">重置</el-button>
+      </el-form-item>
+    </el-form>
+  </com-head>
+
+  <div class="body-layer" style="padding-top: 8px">
+    <el-table
+      class="user-table"
+      :data="state.table.rows"
+      style="width: 100%; max-height: 480px"
+      size="large"
+    >
+      <el-table-column label="序号" width="70" v-slot:default="{ $index }">
+        <div style="text-align: center">
+          {{ state.pag.size * (state.pag.currentPage - 1) + $index + 1 }}
+        </div>
+      </el-table-column>
+      <el-table-column label="组织名称" prop="deptName"></el-table-column>
+      <el-table-column label="组织类型" prop="deptLevelStr"></el-table-column>
+      <el-table-column label="用户姓名" prop="nickName"></el-table-column>
+      <el-table-column label="用户账号" prop="userName"></el-table-column>
+      <el-table-column label="下载时间" prop="createTime"></el-table-column>
+      <el-table-column label="场景标题" prop="sceneTitle"></el-table-column>
+      <el-table-column label="场景码" prop="sceneNum"></el-table-column>
+      <el-table-column label="SN码" prop="snCode"></el-table-column>
+    </el-table>
+
+    <com-pagination
+      @size-change="changPageSize"
+      @current-change="changPageCurrent"
+      :current-page="state.pag.currentPage"
+      :page-size="state.pag.size"
+      :total="state.pag.total"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { usePagging } from "@/hook/pagging";
+import comHead from "@/components/head/index.vue";
+import comCompany from "@/components/company-select/index.vue";
+import comPagination from "@/components/pagination/index.vue";
+import { getDownloadQuoteScene } from "@/store/scene";
+import { ref, watchEffect } from "vue";
+import { dateFormat } from "@/util";
+
+const {
+  state,
+  queryReset: queryResetRaw,
+  refresh,
+  changPageCurrent,
+  changPageSize,
+} = usePagging({
+  get: getDownloadQuoteScene,
+  paramsTemlate: {
+    nickName: "",
+    deptId: "",
+    userName: "",
+    startCreateTime: "",
+    endCreateTime: "",
+    sceneTitle: "",
+    sceneNum: "",
+    snCode: "",
+  },
+});
+
+const defaultTime: [Date, Date] = [
+  new Date(2000, 1, 1, 0, 0, 0),
+  new Date(2000, 2, 1, 23, 59, 59),
+];
+const createTime = ref<Date[] | null>(null);
+watchEffect(() => {
+  if (createTime.value && createTime.value.length === 2) {
+    state.query.startCreateTime = dateFormat(createTime.value[0], "yyyy-MM-dd hh:mm:ss");
+    state.query.endCreateTime = dateFormat(createTime.value[1], "yyyy-MM-dd hh:mm:ss");
+  } else {
+    state.query.startCreateTime = null as any;
+    state.query.endCreateTime = null as any;
+  }
+});
+const queryReset = () => {
+  queryResetRaw();
+  createTime.value = null;
+};
+</script>
+
+<style scoped lang="scss"></style>

+ 23 - 0
src/view/vrmodel/quisk.ts

@@ -1,7 +1,30 @@
 import EditModel from "./editModel.vue";
 import { quiskMountFactory } from "@/helper/mount";
+import SceneDownload from "./sceneDownload.vue";
+import { QuoteScene, SceneType } from "@/store/scene";
+import { axios, checkHasDownload } from "@/request";
 
 export const editModelScene = quiskMountFactory(EditModel, {
   title: "编辑模型",
   width: 500,
 });
+
+export type SceneDpwnloadProps = { scene: QuoteScene };
+export const sceneDownload = async (props: SceneDpwnloadProps) => {
+  const params = {
+    num: props.scene.num,
+    isObj: Number(
+      ![SceneType.SWSS, SceneType.SWYDSS].includes(props.scene.type)
+    ),
+  };
+  const res = await axios.get(checkHasDownload, { params });
+  const hideFloor = Number(res.data.downloadStatus) !== 3;
+
+  const sceneDownloadDialog = quiskMountFactory(SceneDownload, {
+    title: "场景离线包下载",
+    width: 500,
+    hideFloor: hideFloor,
+    enterText: "下 载",
+  });
+  return await sceneDownloadDialog(props);
+};

+ 3 - 2
src/view/vrmodel/sceneContent.vue

@@ -67,14 +67,14 @@
       >
         删除
       </span>
-      <!-- <span
+      <span
         class="oper-span"
         v-pdpath="['download']"
         @click="sceneDownload({ scene: row })"
         v-if="row.num"
       >
         下载
-      </span> -->
+      </span>
     </el-table-column>
   </el-table>
 </template>
@@ -92,6 +92,7 @@ import { ScenePagging } from "./pagging";
 import { QuoteSceneStatusDesc } from "@/constant/scene";
 import { OpenType, openSceneUrl } from "../case/help";
 import { confirm } from "@/helper/message";
+import { sceneDownload } from "./quisk";
 
 const props = defineProps<{ pagging: ScenePagging }>();
 const delSceneHandler = async (scene: QuoteScene) => {

+ 137 - 0
src/view/vrmodel/sceneDownload.vue

@@ -0,0 +1,137 @@
+<template>
+  <!-- hideFloor: state === State.package -->
+  <div>
+    <div class="title">
+      {{ stateTitle[state] }}
+    </div>
+
+    <div v-if="state === State.package">
+      <div
+        class="text"
+        style="display: flex; justify-content: space-between; margin-top: 15px"
+      >
+        <span>{{ filename }}</span>
+        <span>{{ percent }}%</span>
+      </div>
+      <div style="pointer-events: none">
+        <el-slider v-model="percent" :show-tooltip="false" />
+      </div>
+    </div>
+    <div v-else-if="state === State.readDown">
+      <span>正在下载中……</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, onUnmounted, ref } from "vue";
+import saveAs from "@/util/file-serve";
+import { checkHasDownload, getDownloadProcess, downloadScene, axios } from "@/request";
+import { ElLoading, ElMessage } from "element-plus";
+import { QuoteScene, SceneType } from "@/store/scene";
+import { QuiskExpose } from "@/helper/mount";
+
+const props = defineProps<{ scene: QuoteScene }>();
+enum State {
+  uncreate,
+  package,
+  readDown,
+}
+const getState = (type: number) => {
+  const stateTypes = [
+    { codes: [0, 2], state: State.uncreate },
+    { codes: [1], state: State.package },
+    { codes: [3], state: State.readDown },
+  ];
+  return (
+    stateTypes.find((stateType) => stateType.codes.includes(type))?.state ||
+    State.uncreate
+  );
+};
+
+const state = ref<State>(State.uncreate);
+const count = ref<number>(0);
+const filename = ref<string>(props.scene.title + ".zip");
+const downloadURL = ref<string>();
+const percent = ref(0);
+
+const stateTitle = {
+  [State.uncreate]: "下载场景离线数据包,可在本地运行查看。",
+  [State.package]: "正在打包场景离线数据",
+  [State.readDown]: filename.value,
+};
+
+const params = {
+  num: props.scene.num,
+  isObj: Number(![SceneType.SWSS, SceneType.SWYDSS].includes(props.scene.type)),
+};
+// 初始化
+const initial = async () => {
+  const res = await axios.get(checkHasDownload, { params });
+  state.value = getState(res.data.downloadStatus);
+  count.value = res.data.count;
+  downloadURL.value = res.data.downloadUrl;
+
+  if (state.value === State.uncreate) {
+    const downRes = await axios.get(downloadScene, { params });
+    state.value = getState(downRes.data.downloadStatus);
+    // const unCountFlag =
+    //   count.value == 0 ||(await axios.get(downloadScene, { params })).data.downloadStatus !== 1;
+    if (state.value === State.uncreate) {
+      ElMessage.error("下载失败,请联系管理员");
+      throw "暂无剩余下载次数";
+    }
+  }
+
+  if (state.value === State.package) {
+    await new Promise<void>((resolve) => requestUpdateURL(resolve));
+  } else {
+    downloadURL.value = res.data.downloadUrl;
+  }
+};
+
+// 下载
+const download = () => {
+  if (!downloadURL.value) {
+    ElMessage.error("下载链接未生成,请稍等!");
+    throw "下载链接未生成,请稍等!";
+  } else {
+    // if (!downloadURL.value.startsWith("/")) {
+    downloadURL.value = downloadURL.value;
+    // }
+    console.error("downloadURL.value", downloadURL.value);
+    return saveAs(downloadURL.value, filename.value);
+  }
+};
+
+// 进度请求
+let timer: any;
+const requestUpdateURL = async (callback: () => void) => {
+  const res = await axios.get(getDownloadProcess, { params });
+
+  percent.value = parseInt(res.data.percent);
+  downloadURL.value = res.data.url;
+  if (downloadURL.value) {
+    state.value = State.readDown;
+    callback();
+  } else {
+    timer = setTimeout(() => requestUpdateURL(callback), 1000);
+  }
+};
+
+onUnmounted(() => clearTimeout(timer));
+
+defineExpose<QuiskExpose>({
+  submit: async () => {
+    await initial();
+    const loading = ElLoading.service({
+      lock: true,
+      text: "下载中",
+      background: "rgba(255, 255, 255, 0.4)",
+    });
+    await download();
+    loading.close();
+    ElMessage.success("下载完成");
+  },
+});
+</script>

+ 2 - 2
vite.config.ts

@@ -3,12 +3,12 @@ import vue from "@vitejs/plugin-vue";
 import { resolve } from "path";
 import ElementPlus from "unplugin-element-plus/vite";
 
-let app = "ga";
+let app = "criminal";
 if (process.argv.length > 3) {
   app = process.argv[process.argv.length - 1].trim();
 }
 
-const dev = false;
+const dev = true;
 
 export default defineConfig({
   define: {