Преглед изворни кода

火调页面整改和新增媒体库

wangfumin пре 3 месеци
родитељ
комит
f935821723

+ 3 - 0
README.md

@@ -16,3 +16,6 @@ If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has a
    1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
    2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
+
+案件管理只在criminal和ga里面,通过更改packagejson的dev的配置"dev": "vite --mode=criminal",或者
+"dev": "vite --mode=fire",

Разлика између датотеке није приказан због своје велике величине
+ 3856 - 0
package-lock.json


+ 2 - 1
package.json

@@ -30,6 +30,7 @@
     "js-base64": "^3.7.5",
     "mime": "^3.0.0",
     "mitt": "^3.0.1",
+    "public-service": "file:",
     "qrcode.vue": "^3.4.1",
     "qs": "^6.11.2",
     "sass": "^1.64.2",
@@ -51,4 +52,4 @@
     "vite": "^4.4.5",
     "vue-tsc": "^1.8.5"
   }
-}
+}

Разлика између датотеке није приказан због своје велике величине
+ 1771 - 1616
pnpm-lock.yaml


+ 7 - 0
src/app/criminal/routeConfig.ts

@@ -9,6 +9,7 @@ export const CriminalRouteName = {
 export const menuRouteNames = [
   // CriminalRouteName.statistics,
   CriminalRouteName.vrmodel,
+  CriminalRouteName.mediaLibrary,
   CriminalRouteName.camera,
   CriminalRouteName.example,
   CriminalRouteName.organization,
@@ -24,4 +25,10 @@ export const routes: Routes = [
     component: () => import("@/app/criminal/view/example/index.vue"),
     meta: { title: "案件管理", icon: "iconfire_management" },
   },
+  {
+    name: 'criminalDetails',
+    path: "/example/criminalDetails/:caseId",
+    component: () => import("@/app/criminal/view/example/criminalDetails.vue"),
+    meta: { title: "案件管理详情", icon: "iconfire_management" },
+  },
 ];

+ 239 - 0
src/app/criminal/view/example/criminalDetails.vue

@@ -0,0 +1,239 @@
+<template>
+  <div class="fire-details">
+    <div class="sidebar">
+      <el-menu
+        :default-active="currentMenuKey"
+        class="menu-vertical"
+      >
+        <el-menu-item 
+          v-for="menu in menus" 
+          :key="menu.key"
+          :index="menu.key"
+          @click="handleMenuClick(menu)"
+        >
+          {{ menu.label }}
+        </el-menu-item>
+      </el-menu>
+    </div>
+    <div class="content">
+      <!-- 内容区域 -->
+      <div class="content-placeholder">
+        <!-- 这里可以根据选中的菜单项显示对应的内容 -->
+        <SceneList 
+          v-if="currentMenuKey === 'scene'"
+          :case-id="caseId"
+          :on-add-scenes="() => addCaseScenes({ caseId })"
+        />
+        <newCaseFile
+          v-if="!['info', 'scene', 'screenRecord'].includes(currentMenuKey)"
+          :currentMenuKey = currentMenuKey
+          :case-id="caseId"
+        />
+        <div v-if="currentMenuKey === 'screenRecord'" style="height: 100%;display: flex;flex-direction: column;justify-content: center;align-items: center;">
+          基于查看页,实操录制。可录制视频、语音
+          <el-button type="primary" @click="startRecording" style="margin-top: 10px;">
+              开始录制
+          </el-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, onMounted, onUnmounted } from "vue";
+import { useRoute, useRouter } from 'vue-router';
+import { Fire } from "@/app/fire/store/fire";
+import { getFuseCodeLink, checkScenesOpen, MenuItem, getSWKKSyncLink } from "@/view/case/help";
+import { showCaseScenes, addCaseScenes, shareCase, downloadCase } from "@/view/case/quisk";
+import { RouteName, router } from "@/router";
+import { copyCase, getCaseSceneList, getCaseInfo, updateCaseInfo } from "@/store/case";
+import SceneList from "@/view/case/sceneList.vue";
+import newCaseFile from "@/view/case/newCaseFile.vue";
+import { alert } from "@/helper/message";
+import { permission } from "@/store/permission";
+import { addExample, editExample } from "./quisk";
+import { Example, delExample, getExamplePagging } from "@/app/criminal/store/example";
+import { usePagging } from "@/hook/pagging";
+import { title as pageTitle, desc } from "@/store/system"; // 重命名为 pageTitle
+import { appConstant } from "@/app/criminal/constant"; // 导入 appConstant 以便在组件卸载时恢复默认值
+const { state, refresh, queryReset, del, changPageSize, changPageCurrent } = usePagging({
+  get: getExamplePagging,
+  del: delExample,
+  mapper: {
+    delMsg: "删除案件,相关档案也会一并删除,确定要删除吗?",
+  },
+  paramsTemlate: { caseTitle: "", deptId: "" },
+});
+// 从路由获取参数
+const route = useRoute();
+const vueRouter = useRouter();
+const caseId = computed(() => Number(route.params.caseId));
+const routeTitle = computed(() => route.query.title as string); // 重命名为 routeTitle
+
+// 从路由查询参数中获取当前菜单项,如果没有则默认为 'scene'
+const currentMenuKey = ref(route.query.tab as string || 'scene');
+
+// 页面加载时自动触发当前选中的菜单项的点击事件
+let currentRecord = ref<any>({}); // 当前的caseID获取的row
+onMounted(() => {
+  setTimeout(async() => {
+    const caseInfo = await getCaseInfo(caseId.value!);
+    if (caseInfo) {
+      currentRecord.value = caseInfo;
+      // 设置页面标题,使用案件信息
+      pageTitle.value = caseInfo.caseTitle + " | 详情"; // 使用 pageTitle
+      desc.value = "";
+    } else {
+      console.error("该案件不存在!");
+      throw "该案件不存在!";
+    }
+    const menu = menus.value.find(menu => menu.key === currentMenuKey.value);
+    if (menu) {
+      menu.onClick();
+    }
+  }, 0);
+});
+
+// 组件卸载时恢复默认标题
+// onUnmounted(() => {
+//   pageTitle.value = appConstant.title; // 使用 pageTitle
+//   desc.value = appConstant.desc;
+// });
+
+// 处理菜单点击事件
+const handleMenuClick = (menu) => {
+  currentMenuKey.value = menu.key;
+  
+  // 更新路由,保留当前的 tab 参数
+  vueRouter.replace({
+    path: route.path,
+    query: {
+      ...route.query,
+      tab: menu.key
+    }
+  });
+  
+  // 执行菜单项的点击事件
+  menu.onClick();
+};
+
+// 开始录制方法
+const startRecording = () => {
+  if (!caseId.value) return;
+  const currentCaseId = caseId.value;
+  const fuseLink = getFuseCodeLink(currentCaseId);
+  checkScenesOpen(currentCaseId, `${fuseLink}#sceneEdit/record`);
+};
+
+const editHandler = async (example: any) => {
+  if (await editExample({ example })) {
+    refresh();
+  }
+};
+
+const menus = computed(() => {
+  if (!caseId.value) {
+    return [];
+  }
+  const currentCaseId = caseId.value;
+  const fuseLink = getFuseCodeLink(currentCaseId);
+
+  return [
+    {
+      key: "info",
+      disabled: false, // 默认不禁用,与原来不同
+      label: "编辑案件",
+      onClick: () => {
+        editHandler(currentRecord.value.tmProject);
+      },
+    },
+    {
+      key: "scene",
+      disabled: false, // 默认不禁用,与原来不同
+      label: "场景管理",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    },
+    {
+      key: "drawing",
+      disabled: false,
+      label: "绘图管理",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    },
+    {
+      key: "photo",
+      disabled: false,
+      label: "照片制卷",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    },
+    {
+      key: "record",
+      disabled: false,
+      label: "勘验笔录",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    },
+    {
+      key: "list",
+      disabled: false,
+      label: "提取清单",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    },
+    {
+      key: "other",
+      disabled: false,
+      label: "其他资料",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    }, 
+    { 
+      key: "screenRecord", // 修改为唯一的 key,避免与勘验笔录冲突
+      label: "屏幕录制",
+      onClick: () => {
+        checkScenesOpen(currentCaseId, `${fuseLink}#sceneEdit/record`);
+      },
+    },
+  ];
+});
+
+// 获取当前选中的菜单项
+const currentMenu = computed(() => {
+  return menus.value.find(menu => menu.key === currentMenuKey.value);
+});
+</script>
+
+<style lang="scss" scoped>
+.fire-details {
+  display: flex;
+  height: 100%;
+  
+  .sidebar {
+    width: 200px;
+    border-right: 1px solid #e6e6e6;
+    
+    .menu-vertical {
+      height: 100%;
+    }
+  }
+  
+  .content {
+    flex: 1;
+    padding: 20px;
+    
+    .content-placeholder {
+      text-align: center;
+      height: 100%;
+    }
+  }
+}
+</style>

+ 15 - 8
src/app/criminal/view/example/index.vue

@@ -36,7 +36,11 @@
           {{ state.pag.size * (state.pag.currentPage - 1) + $index + 1 }}
         </div>
       </el-table-column>
-      <el-table-column label="标题" prop="caseTitle"></el-table-column>
+      <el-table-column label="标题" prop="caseTitle">
+        <template #default="{ row }">
+          <p class="oper-span tip clickable" @click="gotoQuery(row.caseId)" v-pdpath="['view']">{{ row.caseTitle }}</p>
+        </template>
+      </el-table-column>
       <el-table-column label="承办单位" prop="deptName"></el-table-column>
       <el-table-column label="创建时间" prop="createTime"></el-table-column>
       <el-table-column
@@ -45,20 +49,22 @@
         :width="240"
       >
     
-        <CaseEditMenu
+        <!-- <CaseEditMenu
           :caseId="row.caseId"
           :title="row.caseTitle"
           :prevMenu="[
             { key: 'info', label: '编辑案件', onClick: () => editHandler(row) },
           ]"
           v-if="row.caseId"
-        />
-        <span class="oper-span" @click="gotoQuery(row.caseId)" v-pdpath="['view']"
+        /> -->
+        <EditMenuToDetail :caseId="row.caseId" :fromRoute="'criminal'" :row="row"></EditMenuToDetail>
+        <!-- <span class="oper-span" @click="gotoQuery(row.caseId)" v-pdpath="['view']"
           >查看</span
-        >
-        <span class="oper-span" @click="copy(row.caseId)" v-pdpath="['view']">
+        > -->
+        <MoreMenu :caseId="row.caseId" :title="row.caseTitle" @copy="copy" />
+        <!-- <span class="oper-span" @click="copy(row.caseId)" v-pdpath="['view']">
             复制
-          </span>
+          </span> -->
         <span
           class="oper-span"
           @click="del(row)"
@@ -90,6 +96,8 @@ import CaseEditMenu from "@/view/case/editMenu.vue";
 import { gotoQuery } from "@/view/case/help";
 import { addExample, editExample } from "./quisk";
 import { copyCase } from "@/store/case";
+import EditMenuToDetail from "@/view/case/editMenuToDetail.vue";
+import MoreMenu from "@/view/case/moreMenu.vue";
 
 const { state, refresh, queryReset, del, changPageSize, changPageCurrent } = usePagging({
   get: getExamplePagging,
@@ -99,7 +107,6 @@ const { state, refresh, queryReset, del, changPageSize, changPageCurrent } = use
   },
   paramsTemlate: { caseTitle: "", deptId: "" },
 });
-
 const addHandler = async () => {
   if (await addExample({})) {
     refresh();

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

@@ -10,6 +10,7 @@ export const FireRouteName = {
 
 export const menuRouteNames = [
   FireRouteName.vrmodel,
+  FireRouteName.mediaLibrary,
   FireRouteName.camera,
   FireRouteName.dispatch,
   FireRouteName.teaching,
@@ -41,4 +42,12 @@ export const routes: Routes = [
     component: () => import("./view/dispatch/index.vue"),
     meta: { title: "回收站", icon: "icon-del" },
   },
+  {
+    path: '/fire/dispatch/fireDetails/:caseId',
+    name: 'fireDetails',
+    component: () => import('@/app/fire/view/dispatch/fireDetails.vue'),
+    meta: {
+      title: '火灾详情'
+    }
+  }
 ];

+ 222 - 0
src/app/fire/view/dispatch/fireDetails.vue

@@ -0,0 +1,222 @@
+<template>
+  <div class="fire-details">
+    <div class="sidebar">
+      <el-menu
+        :default-active="currentMenuKey"
+        class="menu-vertical"
+      >
+        <el-menu-item 
+          v-for="menu in menus" 
+          :key="menu.key"
+          :index="menu.key"
+          @click="handleMenuClick(menu)"
+        >
+          {{ menu.label }}
+        </el-menu-item>
+      </el-menu>
+    </div>
+    <div class="content">
+      <!-- 内容区域 -->
+      <div class="content-placeholder">
+        <!-- 这里可以根据选中的菜单项显示对应的内容 -->
+        <SceneList 
+          v-if="currentMenuKey === 'scene'"
+          :case-id="caseId"
+          :on-add-scenes="() => addCaseScenes({ caseId })"
+        />
+        <newCaseFile
+          v-if="!['info', 'scene', 'screenRecord'].includes(currentMenuKey)"
+          :currentMenuKey = currentMenuKey
+          :case-id="caseId"
+        />
+        <div v-if="currentMenuKey === 'screenRecord'" style="height: 100%;display: flex;flex-direction: column;justify-content: center;align-items: center;">
+          基于查看页,实操录制。可录制视频、语音
+          <el-button type="primary" @click="startRecording" style="margin-top: 10px;">
+              开始录制
+          </el-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, onMounted } from "vue";
+import { useRoute, useRouter } from 'vue-router';
+import { Fire } from "@/app/fire/store/fire";
+import { getFuseCodeLink, checkScenesOpen, MenuItem, getSWKKSyncLink } from "@/view/case/help";
+import { showCaseScenes, addCaseScenes, shareCase, downloadCase } from "@/view/case/quisk";
+import { RouteName, router } from "@/router";
+import { copyCase, getCaseSceneList, getCaseInfo, updateCaseInfo } from "@/store/case";
+import SceneList from "@/view/case/sceneList.vue";
+import newCaseFile from "@/view/case/newCaseFile.vue";
+import { title as pageTitle, desc } from "@/store/system"; // 重命名为 pageTitle
+import { appConstant } from "@/app/criminal/constant"; // 导入 appConstant 以便在组件卸载时恢复默认值
+import { addFire, editFire, showLeaveMsgList, addLeaveMsg } from "./quisk";
+import { useFirePagging } from "./pagging";
+
+const { pagging } = useFirePagging();
+// 从路由获取参数
+const route = useRoute();
+const vueRouter = useRouter();
+const caseId = computed(() => Number(route.params.caseId));
+const title = computed(() => route.query.title as string);
+
+// 从路由查询参数中获取当前菜单项,如果没有则默认为 'scene'
+const currentMenuKey = ref(route.query.tab as string || 'scene');
+
+// 页面加载时自动触发当前选中的菜单项的点击事件
+let currentRecord = ref<any>({}); // 当前的caseID获取的row
+onMounted(() => {
+  setTimeout(async() => {
+    const caseInfo = await getCaseInfo(caseId.value!);
+    if (caseInfo) {
+      // 设置页面标题,使用案件信息
+      pageTitle.value = caseInfo.caseTitle + " | 详情"; // 使用 pageTitle
+      desc.value = "";
+      currentRecord.value = caseInfo;
+    } else {
+      console.error("该案件不存在!");
+      throw "该案件不存在!";
+    }
+    const menu = menus.value.find(menu => menu.key === currentMenuKey.value);
+    if (menu) {
+      menu.onClick();
+    }
+  }, 0);
+});
+
+// 处理菜单点击事件
+const handleMenuClick = (menu) => {
+  currentMenuKey.value = menu.key;
+  
+  // 更新路由,保留当前的 tab 参数
+  vueRouter.replace({
+    path: route.path,
+    query: {
+      ...route.query,
+      tab: menu.key
+    }
+  });
+  
+  // 执行菜单项的点击事件
+  menu.onClick();
+};
+
+// 开始录制方法
+const startRecording = () => {
+  if (!caseId.value) return;
+  const currentCaseId = caseId.value;
+  const fuseLink = getFuseCodeLink(currentCaseId);
+  checkScenesOpen(currentCaseId, `${fuseLink}#sceneEdit/record`);
+};
+
+const editHandler = async (row: any) => {
+  (await editFire({ fire: row }));
+};
+
+const menus = computed(() => {
+  if (!caseId.value) {
+    return [];
+  }
+  const currentCaseId = caseId.value;
+  const fuseLink = getFuseCodeLink(currentCaseId);
+
+  return [
+    {
+      key: "info",
+      disabled: false, // 默认不禁用,与原来不同
+      label: "火调信息",
+      onClick: () => {
+        editHandler(currentRecord.value.tmProject);
+      },
+    },
+    {
+      key: "scene",
+      disabled: false, // 默认不禁用,与原来不同
+      label: "场景管理",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    },
+    {
+      key: "drawing",
+      disabled: false,
+      label: "绘图管理",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    },
+    {
+      key: "photo",
+      disabled: false,
+      label: "照片制卷",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    },
+    {
+      key: "record",
+      disabled: false,
+      label: "勘验笔录",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    },
+    {
+      key: "list",
+      disabled: false,
+      label: "提取清单",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    },
+    {
+      key: "other",
+      disabled: false,
+      label: "其他资料",
+      onClick: () => {
+        // 不需要再次设置 currentMenuKey,因为已经在 handleMenuClick 中设置了
+      },
+    }, 
+    { 
+      key: "screenRecord", // 修改为唯一的 key,避免与勘验笔录冲突
+      label: "屏幕录制",
+      onClick: () => {
+        // checkScenesOpen(currentCaseId, `${fuseLink}#sceneEdit/record`);
+      },
+    },
+  ];
+});
+
+// 获取当前选中的菜单项
+const currentMenu = computed(() => {
+  return menus.value.find(menu => menu.key === currentMenuKey.value);
+});
+</script>
+
+<style lang="scss" scoped>
+.fire-details {
+  display: flex;
+  height: 100%;
+  
+  .sidebar {
+    width: 200px;
+    border-right: 1px solid #e6e6e6;
+    
+    .menu-vertical {
+      height: 100%;
+    }
+  }
+  
+  .content {
+    flex: 1;
+    padding: 20px;
+    
+    .content-placeholder {
+      text-align: center;
+      height: 100%;
+    }
+  }
+}
+</style>

+ 11 - 7
src/app/fire/view/dispatch/index.vue

@@ -4,6 +4,7 @@
     :pagging="pagging"
     :checkPerm="isTeached ? 'cancel' : 'teach'"
     :isRecycle="isRecycle"
+    @view-item="row => gotoQuery(row.caseId)"
   >
     <template v-slot:tableCtrl>
       <template v-if="!isRecycle">
@@ -53,7 +54,7 @@
           </span>
         </template>
         <template v-else>
-          <CaseEditMenu
+          <!-- <CaseEditMenu
             :title="row.projectSn"
             :prev-menu="[
               {
@@ -64,13 +65,15 @@
             ]"
             :caseId="row.caseId"
             v-if="row.caseId"
-          />
-          <span class="oper-span" @click="gotoQuery(row.caseId)" v-pdpath="['view']">
+          /> -->
+          <EditMenuToDetail :caseId="row.caseId" :fromRoute="'fire'" :row="row"></EditMenuToDetail>
+          <!-- <span class="oper-span" @click="gotoQuery(row.caseId)" v-pdpath="['view']">
             查看
-          </span>
-          <span class="oper-span" @click="copy(row.caseId)" v-pdpath="['view']">
+          </span> -->
+          <MoreMenu :caseId="row.caseId" :title="row.caseTitle" @copy="copy" />
+          <!-- <span class="oper-span" @click="copy(row.caseId)" v-pdpath="['view']">
             复制
-          </span>
+          </span> -->
           <span
             class="oper-span"
             @click="pagging.del(row)"
@@ -113,6 +116,8 @@ import { useFirePagging } from "./pagging";
 import { Fire, revokeFireTeachs, setFireTeachs, setFire } from "@/app/fire/store/fire";
 import { copyCase } from "@/store/case";
 import CaseEditMenu from "@/view/case/editMenu.vue";
+import EditMenuToDetail from "@/view/case/editMenuToDetail.vue";
+import MoreMenu from "@/view/case/moreMenu.vue";
 import { gotoQuery } from "@/view/case/help";
 import { confirm } from "@/helper/message";
 import { addFire, editFire, showLeaveMsgList, addLeaveMsg } from "./quisk";
@@ -120,7 +125,6 @@ import { shareCase } from "@/view/case/quisk";
 import { ElMessage } from "element-plus";
 
 const { pagging, isTeached, isRecycle } = useFirePagging();
-
 const copy = async (caseId: number) => {
   await copyCase(caseId);
   pagging.refresh();

+ 12 - 2
src/app/fire/view/dispatch/list.vue

@@ -34,9 +34,9 @@
           placement="bottom-start"
           v-if="row.projectName && row.projectName.length > 15"
         >
-          <p class="tip oper-user">{{ row.projectName }}</p>
+          <p class="tip oper-user clickable" @click="$emit('view-item', row)" v-pdpath="['view']">{{ row.projectName }}</p>
         </el-tooltip>
-        <p class="tip" v-else>{{ row.projectName }}</p>
+        <p class="tip clickable" @click="$emit('view-item', row)" v-pdpath="['view']" v-else>{{ row.projectName }}</p>
       </el-table-column>
       <el-table-column label="起火地址" prop="projectAddress" v-slot:default="{ row }">
         <el-tooltip
@@ -103,4 +103,14 @@ import { operateIsPermissionByPath } from "@/directive/permission";
 import { FirePagging } from "./pagging";
 
 defineProps<{ pagging: FirePagging; checkPerm: string }>();
+defineEmits<{
+  (e: 'view-item', row: any): void
+}>();
 </script>
+
+<style scoped>
+.clickable {
+  cursor: pointer;
+  color: #409eff;
+}
+</style>

BIN
src/assets/image/libraryImg/b3dmzip.png


BIN
src/assets/image/libraryImg/egzip.png


BIN
src/assets/image/libraryImg/examplezip.png


BIN
src/assets/image/libraryImg/osgbzip.png


Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
src/assets/svg/media.svg


+ 15 - 1
src/directive/permission.ts

@@ -19,7 +19,21 @@ export const operateIsPermissionByPath = (...operate: string[]) => {
   // if (import.meta.env.DEV) {
   //   return true;
   // }
-  const routeName = router.currentRoute.value.name as string;
+  // 因为架构改变需要在这里处理一些权限的变化
+  if(permission.value.some(item => item.resourceKey === 'scene:sync') && !permission.value.some(item => item.resourceKey === 'dispatch:sync')){
+    permission.value.push({
+      resourceKey: 'dispatch:sync',
+      dataScope: 1,
+      type: 'button'
+    })
+  }
+  let routeName = router.currentRoute.value.name as string;
+  if(['fireDetails'].includes(routeName)){ // 火调详情页需要将权限路由换回dispatch
+    routeName = 'dispatch'
+  }
+  if(['criminalDetails'].includes(routeName)){ // 示例页面需要将权限路由换回example
+    routeName = 'example'
+  }
   const currentRoleName = `${routeName}:${operate.join(":")}`;
   return permission.value.find((item) => item.resourceKey === currentRoleName);
 };

+ 13 - 0
src/request/urls.ts

@@ -253,3 +253,16 @@ export const updateSysSetting = `/fusion/systemSetting/save`;
 
 //相片合成
 export const ffmpegMergeImage = `/fusion/caseImg/ffmpegImage`;
+
+// 媒体库
+export const getMediaList = `/fusion/dictFile/pageList/media-library`;
+export const getAllGroup = `/fusion/dict/pageList/media-library`;
+export const getLibraryGroup = `/fusion/dict/getByKey/media-library`;
+export const addGroupUrl = `/fusion/dict/addOrUpdate/media-library`;
+export const deleteGroupUrl = `/fusion/dict/del/media-library`;
+export const uploadMedia = `/fusion/upload/fileNew`;
+export const editMedia = `/fusion/dictFile/addOrUpdate/media-library`;
+
+export const deleteMedia = `/fusion/dictFile/del/media-library`;
+export const downFile = `/fusion/dictFile/downFile`;
+export const downhash = `/fusion/dictFile/downHash`

+ 7 - 1
src/router/config.ts

@@ -3,7 +3,7 @@ import { appConstant, appRoutes } from "@/app";
 
 export { RouteName };
 export type Routes = Route[];
-export type RouteMeta = { title: string; icon?: string };
+export type RouteMeta = { title: string; icon?: string, iconSrc?: string };
 export type Route = {
   name: string;
   path: string;
@@ -55,6 +55,12 @@ export const routes: Routes = [
         meta: { title: "场景管理", icon: "iconfire_scenes" },
       },
       {
+        name: RouteName.mediaLibrary,
+        path: "mediaLibrary",
+        component: () => import("@/view/mediaLibrary/index.vue"),
+        meta: { title: "媒体库", iconSrc: 'svg/media.svg', icon: "" }, // icon不知道放在哪里的iconfont,新增直接引入svg
+      },
+      {
         name: RouteName.camera,
         path: "camera",
         component: () => import("@/view/camera/index.vue"),

+ 1 - 0
src/router/routeName.ts

@@ -6,6 +6,7 @@ export const RouteName = {
   forget: "forget",
   viewLayout: "viewLayout",
   vrmodel: "scene",
+  mediaLibrary: "mediaLibrary",
   camera: "camera",
   caseFile: "caseFile",
   drawCaseFile: "drawCaseFile",

+ 101 - 0
src/store/mediaLibrary.ts

@@ -0,0 +1,101 @@
+import {
+  axios,
+  getMediaList,
+  getLibraryGroup,
+  addGroupUrl,
+  deleteGroupUrl,
+  getAllGroup,
+  uploadMedia,
+  editMedia,
+  deleteMedia,
+  downFile,
+  downhash,
+  PaggingReq,
+  PaggingRes,
+} from "@/request";
+
+// 媒体库数据类型
+export type Media = {
+  id?: string;
+  dictName: string;
+  fileTypeStr: string;
+  fileFormat: string;
+  fileSize: string;
+  dictId: Number | string;
+  status: string | number;
+  fileType: string;
+  dictNameStr: string;
+  createTime: string;
+  updateTime: string;
+};
+
+// 媒体库分页查询参数类型
+type MediaPaggingParams = PaggingReq<{
+  name: string;
+  fileType: string;  // 确保这里有 fileType 字段
+  dictId: Number | string;    // 分组ID
+}>;
+
+// 获取媒体库列表数据
+export const getMediaPagging = async (params: MediaPaggingParams) => 
+  (await axios.post(getMediaList, params)).data as PaggingRes<Media>;
+
+// 获取分组列表
+export const getGroupList = async ({type, pageNum, pageSize}) =>
+  (await axios.get(getLibraryGroup, { params: { type, pageNum, pageSize } })).data;
+// 获取所有分组列表
+export const getAllGroupList = async (params) =>
+  (await axios.post(getAllGroup, params)).data;
+// 新增分组
+export const addGroupItem = async (dictName: string) =>
+  (await axios.post(addGroupUrl, { dictName })).data;
+// 删除分组
+export const deleteGroupItem = async (dictId: Number | string) =>
+  (await axios.post(deleteGroupUrl, { id: dictId })).data;
+
+// 上传媒体
+export const uploadNewMedia = async (formData) => {
+  return (await axios.post(uploadMedia, formData)).data;
+}
+// 编辑媒体
+export const editMediaItem = async (formData) => {
+  return (await axios.post(editMedia, formData)).data;
+}
+
+// 删除媒体
+export const deleteMediaItem = async (id) => 
+  (await axios.post(deleteMedia, { id })).data;
+
+// 下载媒体
+export const downloadMedia = async (id) => {
+  const response = await axios.get(downFile, { params: {dictFileId: id} });
+  if (response.data) {
+    // 创建一个隐藏的a标签
+    const link = document.createElement('a');
+    link.href = response.data;
+    // 从URL中提取文件名
+    const fileName = response.data.split('/').pop() || `file_${id}`;
+    link.setAttribute('download', fileName);
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+  }
+  return response.data;
+}
+
+// 下载hash
+export const downloadHash = async (id) => {
+  const response = await axios.get(downhash, { params: {dictFileId: id} });
+  if (response.data) {
+    // 创建一个隐藏的a标签
+    const link = document.createElement('a');
+    link.href = response.data;
+    // 从URL中提取文件名
+    const fileName = response.data.split('/').pop() || `hash_${id}.txt`;
+    link.setAttribute('download', fileName);
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+  }
+  return response.data;
+}

+ 0 - 1
src/store/permission.ts

@@ -19,7 +19,6 @@ changSaveLocal("permission", () => permission.value);
  * @param routeNames 所有路由
  */
 export const getPermissionRoutes = (routeNames: string[]) => {
-  console.warn(permission.value);
   return routeNames
     .filter((routeName) =>
       permission.value.some((p) => p.resourceKey === routeName)

+ 1 - 0
src/view/case/caseFile.vue

@@ -142,6 +142,7 @@ const options = computed(() =>
     value: item.filesTypeId,
   }))
 );
+console.log(options)
 const isDraw = computed(() => currentTypeId.value === FileDrawType);
 
 const files = ref<CaseFile[]>([]);

+ 31 - 0
src/view/case/editMenuToDetail.vue

@@ -0,0 +1,31 @@
+<!-- 需求更改,原本编辑editMenu.vue更改为本文件-->
+<template>
+  <!-- 修改后的代码 -->
+<span v-if="fromRoute === 'fire'" class="oper-span" @click="goToDetails(caseId, row)">详情</span>
+<span v-else class="oper-span" @click="goToDetailCriminal(caseId, row)">详情</span>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import { RouteName, router } from "@/router";
+const props = defineProps<{
+  caseId: number;
+  fromRoute: string,
+  row: object;
+}>();
+const goToDetails = (caseId, title) => {
+  const routeData = router.resolve({
+    path: `/fire/dispatch/fireDetails/${caseId}`,
+    // query: { title }
+  });
+  window.open(routeData.href, '_blank');
+};
+const goToDetailCriminal = (caseId, title) => {
+  const routeData = router.resolve({
+    name: 'criminalDetails', // 假设这是正确的路由名称
+    params: { caseId }
+    // query: { title }
+  });
+  window.open(routeData.href, '_blank');
+};
+</script>

+ 94 - 0
src/view/case/moreMenu.vue

@@ -0,0 +1,94 @@
+<template>
+  <el-dropdown>
+    <span class="oper-span">
+      更多
+      <el-icon class="el-icon--right">
+        <arrow-down />
+      </el-icon>
+    </span>
+    <template #dropdown>
+      <el-dropdown-menu>
+        <el-dropdown-item :disabled="menu.permiss ? !operateIsPermissionByPath(menu.permiss, menu.key) : !operateIsPermissionByPath(menu.key)" v-for="menu in menus" :key="menu.key"
+          @click="menu.onClick()">
+          {{ menu.label }}
+        </el-dropdown-item>
+      </el-dropdown-menu>
+    </template>
+  </el-dropdown>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import { shareCase, downloadCase } from "./quisk";
+import { copyCase, getCaseSceneList } from "@/store/case";
+import { alert } from "@/helper/message";
+import { operateIsPermissionByPath } from "@/directive/permission";
+import { usePagging } from "@/hook/pagging";
+import { Example, delExample, getExamplePagging } from "@/app/criminal/store/example";
+const { state, refresh, queryReset, del, changPageSize, changPageCurrent } = usePagging({
+  get: getExamplePagging,
+  del: delExample,
+  mapper: {
+    delMsg: "删除案件,相关档案也会一并删除,确定要删除吗?",
+  },
+  paramsTemlate: { caseTitle: "", deptId: "" },
+});
+const props = defineProps<{
+  caseId: number;
+  title?: string;
+  prevMenu?: MenuItem[];
+  lastMenu?: MenuItem[];
+}>();
+const emit = defineEmits(['copy', 'refresh']);
+const menus = computed(() => {
+  if (!props.caseId) {
+    return [];
+  }
+  const caseId = props.caseId;
+
+  return [
+    ...(props.prevMenu || []).map((item) => ({
+      ...item,
+      onClick: () => item.onClick(caseId),
+    })),
+    {
+      key: "view",
+      label: "复制",
+      permiss: '',
+      onClick: () => {
+        emit('copy', props.caseId);
+      }
+    },
+    {
+      key: "share",
+      label: "分享",
+      permiss: 'edit',
+      onClick: async () => {
+        const scenes = await getCaseSceneList(caseId);
+        if (!scenes.length) {
+          alert("当前案件下无场景,请先添加场景。");
+        } else {
+          shareCase({ caseId: caseId });
+        }
+      },
+    },
+    {
+      key: "download",
+      label: "下载",
+      permiss: 'edit',
+      onClick: async () => {
+        const scenes = await getCaseSceneList(caseId);
+        if (!scenes.length) {
+          alert("当前案件下无场景,请先添加场景。");
+        } else {
+          downloadCase({ caseId, title: props.title || '' });
+        }
+      },
+    },
+    ...(props.lastMenu || []).map((item) => ({
+      ...item,
+      onClick: () => item.onClick(caseId),
+    })),
+  ];
+});
+</script>

+ 284 - 0
src/view/case/newCaseFile.vue

@@ -0,0 +1,284 @@
+<template>
+  <!-- <com-head
+    :options="options"
+    v-model="currentTypeId"
+    notContent
+    v-if="options.length"
+  /> -->
+
+  <div class="new-body-layer body-layer">
+    <template v-if="currentTypeId === 2">
+      <Photos :caseId="caseId" :title="caseInfoData?.caseTitle || ''" />
+    </template>
+    <template v-else-if="currentTypeId === 3">
+      <Records :caseId="caseId" :title="caseInfoData?.caseTitle || ''" />
+    </template>
+    <template v-else-if="currentTypeId === 4">
+      <Manifest :caseId="caseId" :title="caseInfoData?.caseTitle || ''" />
+    </template>
+    <template v-else>
+      <div class="body-head">
+        <h3 style="visibility: hidden">场景管理</h3>
+        <div>
+          <template v-if="isDraw">
+            <el-button type="primary" @click="gotoDraw(BoardType.map, -1)">
+              创建{{ BoardTypeDesc[BoardType.map] }}
+            </el-button>
+            <el-button type="primary" @click="gotoDraw(BoardType.scene, -1)">
+              创建{{ BoardTypeDesc[BoardType.scene] }}
+            </el-button>
+          </template>
+          <el-button type="primary" @click="addCaseFileHandler">
+            上传
+          </el-button>
+        </div>
+      </div>
+
+      <el-table
+        :data="files"
+        tooltip-effect="dark"
+        style="width: 100%"
+        size="large"
+      >
+        <el-table-column label="序号" width="70" v-slot:default="{ $index }">
+          <div style="text-align: center">
+            {{ $index + 1 }}
+          </div>
+        </el-table-column>
+        <el-table-column
+          label="名称"
+          v-slot:default="{ row }: { row: CaseFile }"
+        >
+          <span v-if="!inputCaseTitles.includes(row)">
+            {{ row.filesTitle }}
+            <el-icon class="edit-title" @click="inputCaseTitles.push(row)">
+              <EditPen />
+            </el-icon>
+          </span>
+          <template v-else>
+            <ElInput
+              v-model="row.filesTitle"
+              placeholder="请输入文件名"
+              focus
+              :maxlength="50"
+              style="width: 280px"
+            >
+              <template #append>
+                <el-button type="primary" plain @click="updateFileTitle(row)">
+                  确定
+                </el-button>
+              </template>
+            </ElInput>
+          </template>
+        </el-table-column>
+        <el-table-column label="创建时间" prop="createTime"></el-table-column>
+        <el-table-column
+          label="操作"
+          v-slot:default="{ row }: { row: CaseFile }"
+        >
+          <span class="oper-span" @click="query(row)"> 查看 </span>
+          <span
+            class="oper-span"
+            @click="gotoDraw(row.imgType!, row.filesId)"
+            v-if="row.imgType !== null"
+          >
+            编辑
+          </span>
+          <span class="oper-span delBtn" @click="del(row)"> 删除 </span>
+        </el-table-column>
+      </el-table>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import comHead from "@/components/head/index.vue";
+import { confirm } from "@/helper/message";
+import { RouteName, router } from "@/router";
+import { FileDrawType, BoardTypeDesc } from "@/constant/caseFile";
+import { computed, onMounted, onUnmounted, ref, watchEffect } from "vue";
+import { addCaseFile } from "./quisk";
+import { title, desc } from "@/store/system";
+import {
+  CaseFile,
+  CaseFileType,
+  getCaseFileTypes,
+  getCaseFiles,
+  delCaseFile,
+  BoardType,
+} from "@/store/caseFile";
+import { getCaseInfo, updateCaseInfo } from "@/store/case";
+import { appConstant } from "@/app";
+import { ElIcon, ElInput, ElMessage } from "element-plus";
+import Photos from "./photos/index.vue";
+import Records from "./records/index.vue";
+import Manifest from "./records/manifest.vue";
+
+const props = defineProps<{
+  caseId?: number;
+  currentMenuKey?: string;
+}>();
+
+const caseId = computed(() => {
+  if (props.caseId) {
+    return props.caseId;
+  }
+  const caseId = router.currentRoute.value.params.caseId;
+  if (caseId) {
+    return Number(caseId);
+  }
+});
+const caseInfoData = ref<any>();
+
+const inputCaseTitles = ref<CaseFile[]>([]);
+
+const updateFileTitle = async (caseFile: CaseFile) => {
+  if (!caseFile.filesTitle.trim()) {
+    return ElMessage.error("卷宗标题不能为空!");
+  }
+  await updateCaseInfo(caseFile);
+  inputCaseTitles.value = inputCaseTitles.value.filter(
+    (item) => item !== caseFile
+  );
+};
+
+const currentTypeId = ref<number>();
+const types = ref<CaseFileType[]>([]);
+const options = computed(() =>
+  types.value.map((item) => ({
+    name: item.filesTypeName,
+    value: item.filesTypeId,
+  }))
+);
+
+// 根据currentMenuKey设置currentTypeId
+watchEffect(() => {
+  if (props.currentMenuKey) {
+    const MenuTypeEnum = {
+      drawing: 1,
+      photo: 2,
+      record: 3,
+      list: 4,
+      other: 6
+    };
+    currentTypeId.value = MenuTypeEnum[props.currentMenuKey] || Number(props.currentMenuKey);
+  }
+});
+
+const isDraw = computed(() => currentTypeId.value === FileDrawType);
+
+const files = ref<CaseFile[]>([]);
+const refresh = async () => {
+  files.value = await getCaseFiles({
+    caseId: caseId.value!,
+    filesTypeId: currentTypeId.value,
+  });
+};
+watchEffect(() => caseId.value && currentTypeId.value && refresh());
+
+const query = (file: CaseFile) => {
+  const ext = file.filesUrl
+    .substring(file.filesUrl.lastIndexOf("."))
+    .toLocaleLowerCase();
+    const appId = import.meta.env.VITE_APP_APP ||'fire'
+  if ([".raw", ".dcm"].includes(ext)) {
+    window.open(
+      `/${appId}/xfile-viewer/index.html?file=${file.filesUrl}&name=${file.filesTitle}&time=` +
+        Date.now()
+    );
+  } else {
+    window.open(file.filesUrl + "?time=" + Date.now());
+  }
+};
+const del = async (file: CaseFile) => {
+  if (await confirm("确定要删除此数据?")) {
+    await delCaseFile({ caseId: caseId.value!, filesId: file.filesId });
+    refresh();
+  }
+};
+
+const addCaseFileHandler = async () => {
+  await addCaseFile({ caseId: caseId.value!, fileType: currentTypeId.value! });
+  refresh();
+};
+
+const gotoDraw = (type: BoardType, id: number) => {
+  router.push({
+    name: RouteName.drawCaseFile,
+    params: { caseId: caseId.value!, type, id },
+  });
+};
+
+onMounted(async () => {
+  try {
+    types.value = await getCaseFileTypes();
+    // 如果有传入currentMenuKey,则使用它,否则使用默认值
+    if (props.currentMenuKey) {
+      const MenuTypeEnum = {
+        drawing: 1,
+        photo: 2,
+        record: 3,
+        list: 4,
+        other: 6
+      };
+      currentTypeId.value = MenuTypeEnum[props.currentMenuKey] || Number(props.currentMenuKey);
+    } else {
+      currentTypeId.value = types.value[0].filesTypeId;
+    }
+    
+    // 确保在获取案件信息之前已经有有效的 caseId
+    if (caseId.value) {
+      const caseInfo = await getCaseInfo(caseId.value);
+      if (caseInfo) {
+        caseInfoData.value = caseInfo;
+        title.value = caseInfo.caseTitle + " | 卷宗管理";
+        desc.value = "";
+      } else {
+        console.error("该案件不存在!");
+        throw "该案件不存在!";
+      }
+    } else {
+      console.error("案件ID不存在!");
+      throw "案件ID不存在!";
+    }
+  } catch (error) {
+    console.error("加载案件信息失败:", error);
+    // debugger;
+    //TODO 由于没有登录状态可以判断或hook插入,只能延时进入no-case router当前的router
+    setTimeout(() => {
+      console.log("current-router", router.currentRoute.value.name);
+      if (router.currentRoute.value.name !== "login") {
+        router.replace({ name: RouteName.noCase });
+      }
+    }, 1000);
+  }
+});
+
+onUnmounted(() => {
+  title.value = appConstant.title;
+  desc.value = appConstant.desc;
+});
+</script>
+
+<style scoped lang="scss">
+.new-body-layer {
+  background: transparent;
+  padding-left: 0;
+  height: 100%;
+  :deep(.photo) {
+    .left{
+      padding-left: 24px;
+    }
+    .my-photo-upload{
+      text-align: left;
+    }
+  }
+  :deep(.records) {
+    padding-top: 0;
+  }
+ }
+.edit-title {
+  cursor: pointer;
+  margin-left: 10px;
+}
+</style>

+ 7 - 0
src/view/case/sceneList.vue

@@ -1,5 +1,7 @@
 <template>
   <div class="body-but">
+    <!-- 新需求多元融合放进来这里 -->
+    <el-button type="primary" @click="scenesOpen">多元融合</el-button>
     <el-button type="primary" @click="addScenesHandler">添加场景</el-button>
   </div>
 
@@ -43,6 +45,7 @@ import comDialog from "@/components/dialog/index.vue";
 import { SceneTypeDesc } from "@/constant/scene";
 import { confirm } from "@/helper/message";
 import { getCaseSceneList, getCaseScenes, replaceCaseScenes } from "@/store/case";
+import { getFuseCodeLink, checkScenesOpen, MenuItem, getSWKKSyncLink } from "@/view/case/help";
 import {
   ModelScene,
   ModelSceneStatus,
@@ -76,6 +79,10 @@ const addScenesHandler = async () => {
     refresh();
   }
 };
+const scenesOpen = () => {
+  const fuseLink = getFuseCodeLink(props.caseId);
+  checkScenesOpen(props.caseId, `${fuseLink}#fuseEdit/merge`)
+}
 
 const delCaseScene = async (scene: Scene) => {
   if (await confirm("删除该场景,将同时从案件和融合模型中移除,确定要删除吗?")) {

+ 4 - 1
src/view/layout/slide/submenu.vue

@@ -1,12 +1,15 @@
 <template>
   <el-menu-item :index="name" :key="name">
     <i :class="'iconfont ' + meta.icon" v-if="meta.icon"></i>
+    <img style="margin-right: 8px;" v-else :src="getAssetsFile(meta.iconSrc)" alt="">
     <span>{{ meta.title }}</span>
   </el-menu-item>
 </template>
 
 <script lang="ts" setup>
 import { RouteMeta } from "@/router";
-
 defineProps<{ meta: RouteMeta; name: string }>();
+const getAssetsFile = (path: any) => {
+  return new URL(`../../../assets/${path}`, import.meta.url).href;
+};
 </script>

+ 123 - 0
src/view/mediaLibrary/editMedia.vue

@@ -0,0 +1,123 @@
+<template>
+  <el-dialog
+    title="编辑"
+    v-model="dialogVisible"
+    width="500px"
+    :before-close="handleClose"
+    :close-on-click-modal="false"
+  >
+    <div class="edit-container">
+      <div class="form-item">
+        <span class="required">*</span>分组
+        <el-select v-model="form.dictId" placeholder="请选择">
+          <el-option 
+            v-for="group in groupList" 
+            :key="group.id" 
+            :label="group.dictName" 
+            :value="group.id"
+          ></el-option>
+        </el-select>
+      </div>
+    </div>
+    
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="handleClose">取 消</el-button>
+        <el-button type="primary" @click="handleConfirm">确 认</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, defineEmits, defineProps, computed, watch } from 'vue';
+import { ElMessage } from 'element-plus';
+import { editMediaItem } from '@/store/mediaLibrary';
+
+const props = defineProps({
+  visible: {
+    type: Boolean,
+    default: false
+  },
+  groupList: {
+    type: Array,
+    default: () => []
+  },
+  mediaData: {
+    type: Object,
+    default: () => ({})
+  }
+});
+
+const emit = defineEmits(['update:visible', 'refresh']);
+
+const dialogVisible = computed({
+  get: () => props.visible,
+  set: (val) => emit('update:visible', val)
+});
+
+const form = ref({
+  id: '',
+  dictId: '',
+  uploadId: ''
+});
+
+// 监听媒体数据变化,初始化表单
+watch(() => props.mediaData, (newVal) => {
+  if (newVal && newVal.id) {
+    form.value.id = newVal.id;
+    form.value.dictId = newVal.dictId || '';
+    form.value.uploadId = newVal.uploadId || '';
+  }
+}, { immediate: true, deep: true });
+
+// 关闭弹窗
+const handleClose = () => {
+  dialogVisible.value = false;
+};
+
+// 确认编辑
+const handleConfirm = async () => {
+  if (!form.value.dictId) {
+    ElMessage.warning('请选择分组');
+    return;
+  }
+  
+  try {
+    await editMediaItem({
+      id: form.value.id,
+      dictId: form.value.dictId,
+      uploadId: form.value.uploadId
+    });
+    
+    ElMessage.success('编辑成功');
+    handleClose();
+    emit('refresh');
+  } catch (error) {
+    console.error('编辑失败:', error);
+    ElMessage.error('编辑失败');
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.edit-container {
+  padding: 20px;
+}
+
+.form-item {
+  margin-bottom: 20px;
+  display: flex;
+  align-items: center;
+  
+  .required {
+    color: #f56c6c;
+    margin-right: 5px;
+  }
+  
+  .el-select {
+    margin-left: 10px;
+    width: 300px;
+  }
+}
+</style>

+ 285 - 0
src/view/mediaLibrary/groupManage.vue

@@ -0,0 +1,285 @@
+<template>
+  <el-dialog
+    title="分组管理"
+    v-model="dialogVisible"
+    width="500px"
+    :before-close="handleClose"
+    :close-on-click-modal="false"
+  >
+    <div class="input-area">
+      <el-input
+        v-model="groupName"
+        placeholder="请输入"
+        maxlength="50"
+        show-word-limit
+      ></el-input>
+      <el-button type="primary" @click="addGroup">新增</el-button>
+    </div>
+    <div class="input-area">
+      <div class="search-label">名称</div>
+      <el-input
+        v-model="searchName"
+        placeholder="请名称搜索"
+        maxlength="50"
+        show-word-limit
+      ></el-input>
+      <el-button @click="resetSearch">重 置</el-button>
+      <el-button type="primary" @click="searchGroups">查 询</el-button>
+    </div>
+
+    <div class="group-list">
+      <div class="group-header">
+        <span class="name-title">名称</span>
+        <span class="operation-title">操作</span>
+      </div>
+      <div class="group-content">
+        <div class="group-item" v-for="(item, index) in groupList" :key="index">
+          <span class="group-name">{{ item.dictName }}</span>
+          <span class="group-operation">
+            <span v-if="item.useType !== 'animation'" class="delete-btn" @click="deleteGroup(item)">删除</span>
+          </span>
+        </div>
+      </div>
+    </div>
+
+    <div class="pagination-area">
+      <span class="total-info">共 {{ total }} 条数据</span>
+      <el-pagination
+        background
+        layout="prev, pager, next"
+        :total="total"
+        :current-page="currentPage"
+        :page-size="pageSize"
+        @current-change="handleCurrentChange"
+      />
+      <span class="page-info">{{ currentPage }}/{{ totalPages }}</span>
+    </div>
+
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="handleClose">取 消</el-button>
+        <el-button type="primary" @click="handleConfirm">确 认</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, defineEmits, defineProps, computed, watch } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import { getAllGroupList, addGroupItem, deleteGroupItem } from '@/store/mediaLibrary';
+
+const props = defineProps({
+  visible: {
+    type: Boolean,
+    default: false
+  }
+});
+
+const emit = defineEmits(['update:visible', 'refresh']);
+
+const dialogVisible = computed({
+  get: () => props.visible,
+  set: (val) => emit('update:visible', val)
+});
+
+const groupName = ref('');
+const groupList = ref<any[]>([]);
+const currentPage = ref(1);
+const pageSize = ref(10);
+const total = ref(0);
+const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
+
+const searchName = ref('');
+
+// 获取分组列表
+const fetchGroupList = async () => {
+  try {
+    const params = {
+      pageNum: currentPage.value,
+      pageSize: pageSize.value,
+    };
+    
+    // 添加搜索条件
+    // if (searchName.value) {
+    //   params.dictName = searchName.value;
+    // }
+    
+    const result = await getAllGroupList(params);
+    if (result && result.list) {
+      groupList.value = result.list;
+      total.value = result.total || result.list.length;
+    }
+  } catch (error) {
+    console.error('获取分组列表失败:', error);
+    ElMessage.error('获取分组列表失败');
+  }
+};
+
+// 搜索分组
+const searchGroups = () => {
+  currentPage.value = 1;
+  fetchGroupList();
+};
+
+// 重置搜索
+const resetSearch = () => {
+  searchName.value = '';
+  currentPage.value = 1;
+  fetchGroupList();
+};
+
+// 添加分组
+const addGroup = async () => {
+  if (!groupName.value.trim()) {
+    ElMessage.warning('请输入分组名称');
+    return;
+  }
+  
+  try {
+    await addGroupItem(groupName.value.trim());
+    ElMessage.success('添加分组成功');
+    groupName.value = '';
+    fetchGroupList();
+  } catch (error) {
+    console.error('添加分组失败:', error);
+    ElMessage.error('添加分组失败');
+  }
+};
+
+// 删除分组
+const deleteGroup = async (item: any) => {
+  try {
+    await ElMessageBox.confirm('确定要删除该分组吗?', '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    });
+    
+    await deleteGroupItem(item.id);
+    ElMessage.success('删除分组成功');
+    fetchGroupList();
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('删除分组失败:', error);
+      ElMessage.error('删除分组失败');
+    }
+  }
+};
+
+// 分页处理
+const handleCurrentChange = (page: number) => {
+  currentPage.value = page;
+  fetchGroupList();
+};
+
+// 关闭弹窗
+const handleClose = () => {
+  dialogVisible.value = false;
+};
+
+// 确认按钮
+const handleConfirm = () => {
+  dialogVisible.value = false;
+  emit('refresh');
+};
+
+// 初始化
+const init = () => {
+  currentPage.value = 1;
+  fetchGroupList();
+};
+
+// 监听弹窗显示状态
+
+watch(dialogVisible, (val) => {
+  if (val) {
+    init();
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.input-area {
+  display: flex;
+  margin-bottom: 20px;
+}
+
+.input-area .el-input {
+  flex: 1;
+  margin-right: 10px;
+}
+
+.group-list {
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+}
+
+.group-header {
+  display: flex;
+  background-color: #fafafa;
+  padding: 10px 20px;
+  font-weight: bold;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.name-title {
+  flex: 1;
+}
+
+.operation-title {
+  width: 80px;
+  text-align: center;
+}
+
+.group-content {
+  max-height: 300px;
+  overflow-y: auto;
+}
+
+.group-item {
+  display: flex;
+  padding: 10px 20px;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.group-item:last-child {
+  border-bottom: none;
+}
+
+.group-name {
+  flex: 1;
+}
+
+.group-operation {
+  width: 80px;
+  text-align: center;
+}
+
+.delete-btn {
+  color: #f56c6c;
+  cursor: pointer;
+}
+
+.pagination-area {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 20px;
+}
+
+.total-info {
+  color: #606266;
+  font-size: 14px;
+}
+
+.page-info {
+  color: #606266;
+  font-size: 14px;
+}
+.search-label{
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+}
+</style>

+ 345 - 0
src/view/mediaLibrary/index.vue

@@ -0,0 +1,345 @@
+<template>
+  <com-head :options="[{ name: '媒体库', value: '2' }]">
+    <el-form label-width="97px" inline>
+      <el-form-item label="名称:">
+        <el-input v-model="state.query.name" placeholder="请输入"></el-input>
+      </el-form-item>
+      <el-form-item label="类型:">
+        <el-select v-model="state.query.fileType" clearable placeholder="请选择">
+          <el-option label="图片" value="0"></el-option>
+          <el-option label="视频" value="1"></el-option>
+          <el-option label="音频" value="2"></el-option>
+          <el-option label="模型" value="3"></el-option>
+          <el-option label="其他" value="4"></el-option>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="分组:">
+        <el-select v-model="state.query.dictId" clearable placeholder="请选择">
+          <el-option v-for="group in groupList" :key="group.id" :label="group.dictName" :value="group.id"></el-option>
+        </el-select>
+      </el-form-item>
+      <el-form-item class="searh-btns" style="grid-area: 1 / 4 / 2 / 4">
+        <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">
+    <div class="body-head">
+      <h3 style="visibility: hidden">媒体列表</h3>
+      <div class="table-ctrl-right">
+        <el-button type="primary" @click="groupManage" v-pdpath="'group'">分组管理</el-button>
+        <el-button type="primary" @click="uploadMediaHandler" v-pdpath="'upload'"> 上传 </el-button>
+      </div>
+    </div>
+    <el-table
+      ref="multipleTable"
+      :data="state.table.rows"
+      size="large"
+      style="width: 100%"
+    >
+      <!-- <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="名称">
+        <template #default="{ row }">
+          <p class="oper-span tip clickable" @click="floadileUrl(row)">{{ row.fileName }}</p>
+        </template>
+      </el-table-column>
+      <el-table-column label="文件类型" prop="fileTypeStr"></el-table-column>
+      <el-table-column label="文件格式" prop="fileFormat"></el-table-column>
+      <el-table-column label="大小" prop="fileSize">
+        <template #default="{ row }">
+          {{ formatFileSize(row.fileSize) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="分组" prop="dictName"></el-table-column>
+      <el-table-column label="状态" prop="status">
+        <template #default="{ row }">
+          {{ formatStatus(row.status) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="上传时间" width="200px" prop="updateTime">
+        <template #default="{ row }">
+          {{ formatDateTime(row.updateTime) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="200px" v-slot:default="{ row }">
+        <span class="oper-span" @click="downloadMediaHandler(row)" v-pdpath="'view'">下载</span>
+        <span class="oper-span" @click="downloadHashHandler(row)" v-pdpath="'view'">hash</span>
+        <span class="oper-span" v-if="row.useType != 'animation'" @click="editMediaHandler(row)" v-pdpath="'view'">编辑</span>
+        <span class="oper-span delBtn" v-if="row.useType != 'animation'" @click="confirmDelete(row)" v-pdpath="'view'"> 删除 </span>
+      </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>
+  <GroupManage 
+    v-model:visible="groupManageVisible" 
+    @refresh="refreshList" 
+  />
+  <UploadMedia
+    v-model:visible="uploadVisible"
+    :groupList="groupList"
+    @refresh="refreshList"
+  />
+  <EditMedia
+    v-model:visible="editVisible"
+    :groupList="groupList"
+    :mediaData="currentMedia"
+    @refresh="refreshList"
+  />
+</template>
+
+<script setup lang="ts">
+import comSelect from "@/components/company-select/index.vue";
+import comPagination from "@/components/pagination/index.vue";
+import comHead from "@/components/head/index.vue";
+import { usePagging } from "@/hook/pagging";
+import { confirm } from "@/helper/message";
+import { ElMessage, ElMessageBox } from "element-plus";
+import { getMediaPagging, uploadNewMedia, deleteMediaItem, Media, getGroupList, downloadMedia, downloadHash } from "@/store/mediaLibrary";
+import { ref, onMounted, watch } from "vue";
+import GroupManage from './groupManage.vue';
+import UploadMedia from './uploadMedia.vue';
+import EditMedia from './editMedia.vue';
+
+// 分组列表数据
+const groupList = ref<Array<any>>([]);
+
+// 获取分组列表
+const fetchGroupList = async () => {
+  try {
+    const result = await getGroupList({type: 1});
+    if (result && Array.isArray(result)) {
+      groupList.value = result;
+    }
+  } catch (error) {
+    console.error('获取分组列表失败:', error);
+  }
+};
+
+// 在组件挂载时获取分组数据
+onMounted(() => {
+  fetchGroupList();
+});
+
+// 格式化文件大小为MB
+const formatFileSize = (size: string | number) => {
+  if (!size && size !== 0) return '-';
+  
+  // 将字符串转为数字
+  const sizeNum = typeof size === 'string' ? parseFloat(size) : size;
+  
+  // 转换为MB并保留两位小数
+  const sizeMB = (sizeNum / (1024 * 1024)).toFixed(2);
+  
+  return `${sizeMB} MB`;
+};
+
+// 格式化状态为中文
+const formatStatus = (status: string | number) => {
+  if (status === undefined || status === null) return '-';
+  
+  // 将字符串转为数字
+  const statusNum = typeof status === 'string' ? parseInt(status) : status;
+  
+  // 根据状态码返回对应的中文
+  switch (statusNum) {
+    case 1:
+      return '上传成功';
+    case 0:
+      return '上传中';
+    case -1:
+      return '上传失败';
+    default:
+      return '未知状态';
+  }
+};
+
+// 格式化日期时间
+const formatDateTime = (dateTimeStr: string) => {
+  if (!dateTimeStr) return '-';
+  
+  try {
+    // 创建日期对象
+    const date = new Date(dateTimeStr);
+    
+    // 格式化为 YYYY-MM-DD HH:MM:SS
+    const year = date.getFullYear();
+    const month = String(date.getMonth() + 1).padStart(2, '0');
+    const day = String(date.getDate()).padStart(2, '0');
+    const hours = String(date.getHours()).padStart(2, '0');
+    const minutes = String(date.getMinutes()).padStart(2, '0');
+    const seconds = String(date.getSeconds()).padStart(2, '0');
+    
+    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
+  } catch (error) {
+    console.error('日期格式化错误:', error);
+    return dateTimeStr; // 如果格式化失败,返回原始字符串
+  }
+};
+
+const { state, queryReset, refresh, changPageCurrent, changPageSize, add, del } = usePagging({
+  get: getMediaPagging,
+  add: uploadNewMedia,
+  del: deleteMediaItem,
+  paramsTemlate: {
+    name: "",
+    fileType: "",
+    dictId: "",
+  },
+  mapper: {
+    delMsg: "确定要删除该媒体吗?"
+  }
+});
+
+// 上传媒体弹窗
+const uploadVisible = ref(false);
+
+// 上传媒体处理函数
+const uploadMediaHandler = async () => {
+  uploadVisible.value = true;
+};
+
+// 编辑媒体弹窗
+const editVisible = ref(false);
+const currentMedia = ref({});
+
+// 编辑媒体处理函数
+const editMediaHandler = async (row: Media) => {
+  currentMedia.value = row;
+  editVisible.value = true;
+};
+
+// 通用下载处理函数
+const handleDownload = async (row: Media, type: 'media' | 'hash') => {
+  try {
+    let url = '';
+    let fileName = '';
+    
+    // 根据类型确定URL和文件名
+    if (type === 'media') {
+      if (row.downUrl) {
+        url = row.downUrl;
+      } else {
+        // 通过接口获取URL
+        url = await downloadMedia(row.id);
+        if (!url) {
+          ElMessage.error('获取下载链接失败');
+          return;
+        }
+      }
+      fileName = `${row.fileName}.${row.fileFormat}` || url.split('/').pop() || `file_${row.id}`;
+    } else {
+      if (row.hashUrl) {
+        url = row.hashUrl;
+      } else {
+        // 通过接口获取URL
+        url = await downloadHash(row.id);
+        if (!url) {
+          ElMessage.error('获取Hash下载链接失败');
+          return;
+        }
+      }
+      fileName = `${row.fileName || 'file'}.txt`;
+    }
+    
+    // 使用fetch获取文件内容
+    const response = await fetch(url);
+    const blob = await response.blob();
+    
+    // 创建Blob URL
+    const blobUrl = window.URL.createObjectURL(blob);
+    
+    // 创建下载链接
+    const link = document.createElement('a');
+    link.href = blobUrl;
+    link.setAttribute('download', fileName);
+    document.body.appendChild(link);
+    link.click();
+    
+    // 清理
+    document.body.removeChild(link);
+    window.URL.revokeObjectURL(blobUrl);
+    
+    ElMessage.success(type === 'media' ? '下载成功' : 'Hash下载成功');
+  } catch (error) {
+    console.error(type === 'media' ? '下载失败:' : 'Hash下载失败:', error);
+    ElMessage.error(type === 'media' ? '下载失败' : 'Hash下载失败');
+  }
+};
+
+// 下载媒体处理函数
+const downloadMediaHandler = async (row: Media) => {
+  await handleDownload(row, 'media');
+};
+
+// 下载hash处理函数
+const downloadHashHandler = async (row: Media) => {
+  await handleDownload(row, 'hash');
+};
+
+// 分组管理弹窗
+const groupManageVisible = ref(false);
+
+// 打开分组管理弹窗
+const groupManage = () => {
+  groupManageVisible.value = true;
+};
+
+// 刷新列表
+const refreshList = () => {
+  refresh();
+  fetchGroupList(); // 刷新分组列表
+};
+
+// 跳转
+const floadileUrl = (row: any) => {
+    if (row.fileType == 3) {
+      let url = `/code/index.html?title=${row.fileName}&type=${row.fileFormat}&fileUrl=${row.fileUrl}#/sign-model`
+      return window.open(url);
+    } else {
+      return window.open(row.fileUrl);
+    }
+}
+
+// 确认删除
+const confirmDelete = async (row: Media) => {
+  try {
+    await ElMessageBox.confirm('确定删除吗?', '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    });
+    
+    // 用户点击确认,执行删除操作
+    try {
+      await deleteMediaItem(row.id);
+      ElMessage.success('删除成功');
+      refresh(); // 刷新列表
+    } catch (error) {
+      console.error('删除失败:', error);
+      ElMessage.error('删除失败');
+    }
+  } catch {
+    // 用户点击取消,不执行任何操作
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.mediaLibrary {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 217 - 0
src/view/mediaLibrary/uploadMedia.vue

@@ -0,0 +1,217 @@
+<template>
+  <el-dialog
+    title="上传"
+    v-model="dialogVisible"
+    width="500px"
+    :before-close="handleClose"
+    :close-on-click-modal="false"
+  >
+    <div class="upload-container">
+      <div class="group-select">
+        <span class="required">*</span>组别
+        <el-select v-model="selectedGroup" placeholder="请选择">
+          <el-option 
+            v-for="group in groupList" 
+            :key="group.id" 
+            :label="group.dictName" 
+            :value="group.id"
+          ></el-option>
+        </el-select>
+      </div>
+      
+      <div class="upload-area">
+        <el-upload
+          class="upload-box"
+          action="#"
+          :auto-upload="false"
+          :on-change="handleFileChange"
+          :file-list="fileList"
+          :multiple="true"
+        >
+          <div class="upload-button">
+            <span>文件</span>
+            <el-button type="primary">上传</el-button>
+          </div>
+        </el-upload>
+      </div>
+      
+      <div class="upload-tips">
+        <div class="tip-item">
+          <div class="tip-content">支持jpg、png、jpeg、mp4、wav、mp3、3ds格式文件上传,文件大小小于 2G</div>
+        </div>
+        
+        <div class="tip-item">
+          <div class="tip-content">上传.zip:需要zip内上传,包含数据和模型,mix文件包含不使用的文件,文件名不使用中文字符,如:</div>
+          <div class="tip-example">
+            <img src="@/assets/image/libraryImg/examplezip.png" alt="示例zip" />
+          </div>
+        </div>
+        
+        <div class="tip-item">
+          <div class="tip-content">上传.gltf/.glb:需要zip内上传,包含不使用自定义文件,文件名不使用中文字符,文件大小小于 1G,如:</div>
+          <div class="tip-example">
+            <img src="@/assets/image/libraryImg/egzip.png" alt="示例gltf" />
+          </div>
+        </div>
+        
+        <div class="tip-item">
+          <div class="tip-content">上传.osgb:需要zip内上传,包含 Data 文件夹,xml 文件,包含不使用的文件,文件名不使用中文字符,文件大小小于 2G,如:</div>
+          <div class="tip-example">
+            <img src="@/assets/image/libraryImg/osgbzip.png" alt="示例osgb" />
+          </div>
+        </div>
+        
+        <div class="tip-item">
+          <div class="tip-content">上传.b3dm:需要zip内上传,包含 Tileset.json 文件,包含json文件,包含不使用的文件,文件名不使用中文字符,文件大小小于 1G,如:</div>
+          <div class="tip-example">
+            <img src="@/assets/image/libraryImg/b3dmzip.png" alt="示例b3dm" />
+          </div>
+        </div>
+      </div>
+    </div>
+    
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="handleClose">取 消</el-button>
+        <el-button type="primary" @click="handleUpload">确 定</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, defineEmits, defineProps, computed } from 'vue';
+import { ElMessage } from 'element-plus';
+import { uploadNewMedia } from '@/store/mediaLibrary';
+
+const props = defineProps({
+  visible: {
+    type: Boolean,
+    default: false
+  },
+  groupList: {
+    type: Array,
+    default: () => []
+  }
+});
+
+const emit = defineEmits(['update:visible', 'refresh']);
+
+const dialogVisible = computed({
+  get: () => props.visible,
+  set: (val) => emit('update:visible', val)
+});
+
+const selectedGroup = ref('');
+const fileList = ref([]);
+
+// 处理文件变化
+const handleFileChange = (file, files) => {
+  fileList.value = files;
+};
+
+// 关闭弹窗
+const handleClose = () => {
+  dialogVisible.value = false;
+  selectedGroup.value = '';
+  fileList.value = [];
+};
+
+// 上传文件
+const handleUpload = async () => {
+  if (!selectedGroup.value) {
+    ElMessage.warning('请选择分组');
+    return;
+  }
+  
+  if (fileList.value.length === 0) {
+    ElMessage.warning('请选择要上传的文件');
+    return;
+  }
+  
+  try {
+    // 这里实现文件上传逻辑
+    const formData = new FormData();
+    formData.append('dictId', selectedGroup.value);
+    
+    fileList.value.forEach(file => {
+      formData.append('file', file.raw);
+    });
+    
+    await uploadNewMedia(formData);
+    
+    ElMessage.success('上传成功');
+    handleClose();
+    emit('refresh');
+  } catch (error) {
+    console.error('上传失败:', error);
+    ElMessage.error('上传失败');
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.upload-container {
+  padding: 0 20px;
+  max-height: 600px;
+  overflow-y: auto;
+}
+
+.group-select {
+  margin-bottom: 20px;
+  display: flex;
+  align-items: center;
+  
+  .required {
+    color: #f56c6c;
+    margin-right: 5px;
+  }
+  
+  .el-select {
+    margin-left: 10px;
+    width: 200px;
+  }
+}
+
+.upload-area {
+  margin-bottom: 20px;
+  
+  .upload-button {
+    display: flex;
+    align-items: center;
+    
+    span {
+      margin-right: 10px;
+    }
+  }
+}
+
+.upload-tips {
+  .tip-item {
+    margin-bottom: 15px;
+    
+    .tip-number {
+      font-weight: bold;
+      margin-bottom: 5px;
+      display: inline-block;
+    }
+    
+    .tip-content {
+      margin-bottom: 5px;
+      color: #606266;
+      font-size: 14px;
+    }
+    
+    .tip-example {
+      margin-top: 5px;
+      border: 1px solid #ebeef5;
+      padding: 10px;
+      background-color: #f5f7fa;
+      
+      img {
+        max-width: 100%;
+      }
+    }
+  }
+}
+</style>

Разлика између датотеке није приказан због своје велике величине
+ 2091 - 1074
yarn.lock