ソースを参照

离线包功能

wangfumin 2 週間 前
コミット
a1cf530ca8

+ 2 - 2
.env.offline

@@ -5,7 +5,7 @@ VITE_SERVICE_URL=http://localhost
 VITE_SWSS_URL=http://localhost
 VITE_LASER_URL=http://localhost
 VITE_FDKK_URL=http://localhost
-
+VITE_APP_APP = "fire"
 # Enable offline mode
 VITE_OFFLINE=true
-VITE_OFFLINE_DATA_PATH=/offline-data
+VITE_OFFLINE_DATA_PATH=./

+ 4 - 0
src/directive/permission.ts

@@ -4,9 +4,11 @@ import { DataScope, RoleLevel, roleLevel } from "@/store/role";
 import { Scene } from "@/store/scene";
 import { user } from "@/store/user";
 import { App } from "vue";
+import { isOfflineMode } from "@/util/offline";
 
 // 操作某个场景 查看是否有权限
 export const operateIsPermissionByScene = (scene: Scene) => {
+  if (isOfflineMode()) return true;
   return true;
   // return (
   //   [RoleLevel.admin, RoleLevel.systemAdmin].includes(roleLevel.value) ||
@@ -16,6 +18,7 @@ export const operateIsPermissionByScene = (scene: Scene) => {
 
 // 查看某个操作是否有权限
 export const operateIsPermissionByPath = (...operate: string[]) => {
+  if (isOfflineMode()) return true as any;
   // if (import.meta.env.DEV) {
   //   return true;
   // }
@@ -40,6 +43,7 @@ export const operateIsPermissionByPath = (...operate: string[]) => {
 
 // 查看某个数据操作是否有权限操作
 export const operateIsPermissionByDept = (data: any[]) => {
+  if (isOfflineMode()) return true;
   const permission = operateIsPermissionByPath(...data.slice(1));
   if (!permission) {
     return false;

+ 18 - 11
src/request/index.ts

@@ -14,6 +14,7 @@ import {
 } from "./config";
 import { router } from "@/router";
 import { RouteName } from "@/router/config";
+import { isOfflineMode } from "@/util/offline";
 
 export * from "./urls";
 export * from "./config";
@@ -95,10 +96,13 @@ axios.interceptors.request.use(async (config) => {
   })();
 
   if (!hasIgnore) {
-    if (!token && !~notLoginUrls.indexOf(config.url) && !shareBypassLogin) {
-      const redirect = window.location.href;
-      router.replace({ name: RouteName.login, query: { redirect } });
-      throw "用户未登录";
+    const offline = isOfflineMode();
+    if (!offline) {
+      if (!token && !~notLoginUrls.indexOf(config.url) && !shareBypassLogin) {
+        const redirect = window.location.href;
+        router.replace({ name: RouteName.login, query: { redirect } });
+        throw "用户未登录";
+      }
     }
   }
 
@@ -147,13 +151,16 @@ const responseInterceptor = (res: AxiosResponse<any, any>) => {
     let errMsg = res.data.msg || res.data.message;
     openErrorMsg(errMsg);
 
-    if (
-      ~unAuthCode.indexOf(res.data.code) ||
-      errMsg === "token已经失效,请重新登录"
-    ) {
-      getAuth().clear();
-      const redirect = window.location.href;
-      router.replace({ name: RouteName.login, query: { redirect } });
+    const offline = isOfflineMode();
+    if (!offline) {
+      if (
+        ~unAuthCode.indexOf(res.data.code) ||
+        errMsg === "token已经失效,请重新登录"
+      ) {
+        getAuth().clear();
+        const redirect = window.location.href;
+        router.replace({ name: RouteName.login, query: { redirect } });
+      }
     }
     throw res.data.msg;
   }

+ 45 - 23
src/router/index.ts

@@ -15,6 +15,7 @@ import {
 } from "@/request";
 import { ElMessageBox, ElMessage } from "element-plus";
 import md5 from "js-md5";
+import { isOfflineMode, getOfflineDataPath, fetchOfflineJson } from "@/util/offline";
 
 export * from "./config";
 export * from "./routeName";
@@ -42,37 +43,58 @@ router.beforeEach((to, from, next) => {
     }
     return;
   }
-  const applyThemeAndNext = () => {
+  const applyThemeAndNext = async () => {
     try {
-      axios
-        .get(getSysSetting, {
-          params: {
-            platformKey: appId,
-          },
-        })
-        .then(async (data) => {
-          localStorage.setItem('f-themeColour', data.data.themeColour);
-          const key = Object.keys(modules).find((key) =>
-            key.includes(data.data.themeColour)
-          );
-          if (key) {
-            const res1: any = await modules[key]();
-            const res2: any = await import('@/assets/style/public.scss?inline');
-            $style.innerHTML = res1.default + res2.default;
+      // 离线模式:从本地离线包的 data.json 读取主题色
+      if (isOfflineMode()) {
+        try {
+          const base = getOfflineDataPath();
+          const dict = await fetchOfflineJson<Record<string, any>>([
+            `${base}/package/data.json`,
+            `/package/data.json`,
+          ]);
+          const entry = dict["/fusion/systemSetting/list"];
+          const list: any[] = Array.isArray(entry?.data)
+            ? entry.data
+            : (Array.isArray(entry?.list) ? entry.list : []);
+          const item = list.find((it) => it.platformKey === appId);
+          if (item) {
+            localStorage.setItem('f-themeColour', item.themeColour);
+            const key = Object.keys(modules).find((key) => key.includes(item.themeColour));
+            if (key) {
+              const res1: any = await modules[key]();
+              const res2: any = await import('@/assets/style/public.scss?inline');
+              $style.innerHTML = res1.default + res2.default;
+            }
           }
-          next();
-        })
-        .catch(() => {
-          // 主题加载失败也不阻塞路由
-          next();
-        });
+        } catch (e) {
+          // 离线包主题加载失败也不阻塞路由
+        } finally {
+          return next();
+        }
+      }
+
+      // 在线模式:调用后端接口获取主题色
+      const data: any = await axios.get(getSysSetting, { params: { platformKey: appId } });
+      localStorage.setItem('f-themeColour', data.data.themeColour);
+      const key = Object.keys(modules).find((key) => key.includes(data.data.themeColour));
+      if (key) {
+        const res1: any = await modules[key]();
+        const res2: any = await import('@/assets/style/public.scss?inline');
+        $style.innerHTML = res1.default + res2.default;
+      }
+      next();
     } catch (error) {
+      // 主题加载失败也不阻塞路由
       next();
     }
   };
 
-  // 分享链接访问:在进入 fireDetails 前校验密码
+  // 分享链接访问:在进入 fireDetails 前校验密码(离线模式下跳过)
   if (to.name === 'fireDetails' && String((to.query as any)?.share || '') === '1') {
+    if (isOfflineMode()) {
+      return applyThemeAndNext();
+    }
     const linkPwd = String((to.query as any)?.p || '');
     const caseId = String((to.params as any)?.caseId || '');
 

+ 35 - 12
src/setSystem.ts

@@ -13,25 +13,48 @@ import {
   getSysSetting,
   updateSysSetting,
 } from "@/request";
+import { isOfflineMode, getOfflineDataPath, fetchOfflineJson } from "@/util/offline";
 
 const modules = import.meta.glob("@/assets/style/theme/*.scss", {
   query: "?inline",
 });
 
-const appId = import.meta.env.VITE_APP_APP
+const appId = import.meta.env.VITE_APP_APP;
 
-console.log('获取后台当前色appId', appId)
-axios.get(getSysSetting, {
-  params: {
-    platformKey: appId
+const initSysSetting = async () => {
+  console.log(isOfflineMode(), import.meta.env.VITE_OFFLINE, 77777777)
+  if (isOfflineMode()) {
+    try {
+      const base = getOfflineDataPath();
+      console.log(base, 88888888)
+      const dict = await fetchOfflineJson<Record<string, any>>([
+        `${base}/package/data.json`,
+        `/package/data.json`,
+      ]);
+      const entry = dict["/fusion/systemSetting/list"];
+      const list: any[] = Array.isArray(entry?.data)
+        ? entry.data
+        : (Array.isArray(entry?.list) ? entry.list : []);
+      const item = list.find((it) => it.platformKey === appId);
+      if (item) {
+        systemData.value.name = item.title;
+        systemData.value.color = item.themeColour;
+        localStorage.setItem('f-themeColour', item.themeColour);
+        refresh();
+        return;
+      }
+    } catch (e) {
+    }
   }
-}).then((data) => {
-  systemData.value.name = data.data.title;
-  systemData.value.color = data.data.themeColour;
-  console.log('获取后台当前色', data.data.themeColour)
-  localStorage.setItem('f-themeColour', data.data.themeColour)
-  refresh();
-});
+  axios.get(getSysSetting, { params: { platformKey: appId } }).then((data) => {
+    systemData.value.name = data.data.title;
+    systemData.value.color = data.data.themeColour;
+    localStorage.setItem('f-themeColour', data.data.themeColour);
+    refresh();
+  });
+};
+
+initSysSetting();
 
 const update = () => {
   axios.post(updateSysSetting, {

+ 107 - 56
src/store/editCsae.ts

@@ -14,6 +14,8 @@ import {
   // 文件相关列表接口
   caseFileTypes,
   caseFiles,
+  // 文件详情接口(含图片/勘验/提取等不同类型详情)
+  caseFileInfo,
   // 案件基础信息与场景列表接口
   caseInfo,
   caseSceneList,
@@ -29,17 +31,51 @@ import {
 import { uploadFile } from '@/store/system';
 import { isOfflineMode, getOfflineDataPath, fetchOfflineJson } from '@/util/offline';
 
+// ================== 离线包:统一从 /package/data.json 读取接口数据 ==================
+// data.json 结构为 { "<url>?<query>": { code, data, ... }, ... }
+const fetchOfflineDataDict = async (): Promise<Record<string, any>> => {
+  const base = getOfflineDataPath();
+  const candidates = [
+    `${base}/package/data.json`,
+    `/package/data.json`,
+  ];
+  return await fetchOfflineJson<Record<string, any>>(candidates);
+};
+
+const findDataJsonEntry = (
+  dict: Record<string, any>,
+  url: string,
+  params?: Record<string, any>
+): any => {
+  let targetPath = `${url}`;
+  if (params) {
+    const pKeys = Object.keys(params);
+    targetPath += `?${pKeys.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join('&')}`;
+  } else {
+    targetPath = url;
+  }
+  if(targetPath){
+    for(const key of Object.keys(dict)){
+      if(key.includes(targetPath)){
+        return dict[key];
+      }
+    }
+  }
+  return null;
+};
+
+const offlineByDataJson = async <T = any>(url: string, params?: Record<string, any>): Promise<T> => {
+  const dict = await fetchOfflineDataDict();
+  const entry = findDataJsonEntry(dict, url, params);
+  const data = entry?.data ?? entry?.list ?? entry?.value ?? entry?.detail ?? entry?.result ?? entry;
+  return data as T;
+};
+
 export const getRecordCaseVideo = async (props) => {
   if (isOfflineMode()) {
-    const base = getOfflineDataPath();
     const caseId = props?.caseId ?? props?.id;
-    const candidates = [
-      `${base}/record/${caseId}.json`,
-      `${base}/record-case-${caseId}.json`,
-      `${base}/case-${caseId}-record.json`,
-    ];
     try {
-      const data: any = await fetchOfflineJson<any>(candidates);
+      const data: any = await offlineByDataJson<any>(recordCaseVideo, { caseId });
       return Array.isArray(data) ? data : (data?.list ?? data?.data ?? []);
     } catch (e) {
       return [];
@@ -51,23 +87,13 @@ export const getRecordCaseVideo = async (props) => {
 export const getFusionAndSceneList = async (props) => {
   // 离线模式:优先从本地静态 JSON 读取
   if (isOfflineMode()) {
-    const base = getOfflineDataPath();
-    const caseId = props?.caseId ?? props?.id;
-    const type = props?.type || 'scene';
-    const candidates = [
-      `${base}/case-${caseId}-${type}.json`,
-      `${base}/case-${caseId}.json`,
-      `${base}/${type}/${caseId}.json`,
-    ];
     try {
-      const data: any = await fetchOfflineJson<any>(candidates);
+      const data: any = await offlineByDataJson<any>(getFusionAndScene, props);
       if (Array.isArray(data)) return data;
-      if (data && Array.isArray(data[type])) return data[type];
       if (data && Array.isArray(data.list)) return data.list;
       if (data && Array.isArray(data.data)) return data.data;
       return [];
     } catch (e) {
-      // 未找到离线数据时返回空数组,避免页面阻塞
       return [];
     }
   }
@@ -144,19 +170,29 @@ export const updateRecordInfo = async (props: {
 };
 
 // 获取全部文件类型
-export const caseFilesTypeGetTree = async (caseId: number) => (await axios.get(caseFilesTypeGetByTree, { params: { caseId, ingoreRes: true } })).data;
+export const caseFilesTypeGetTree = async (caseId: number) => {
+  if (isOfflineMode()) {
+    try {
+      const data: any = await offlineByDataJson<any>(caseFilesTypeGetByTree, { caseId });
+      return data;
+    } catch (e) {
+      return [];
+    }
+  }
+  return (await axios.get(caseFilesTypeGetByTree, { params: { caseId, ingoreRes: true } })).data;
+};
 
 // =============== 其他资料 & 勘验:列表接口(离线支持) ===============
 export const getCaseFileTypesOffline = async (): Promise<any[]> => {
   if (isOfflineMode()) {
-    const base = getOfflineDataPath();
-    const candidates = [
-      `${base}/case-file-types.json`,
-      `${base}/files/types.json`,
-    ];
     try {
-      const data: any = await fetchOfflineJson<any>(candidates);
-      return Array.isArray(data) ? data : (data?.list ?? data?.data ?? []);
+      // 若 data.json 不包含 allList,则前端可用 getByTree 替代
+      const dataAll: any = await offlineByDataJson<any>(caseFileTypes, {});
+      if (dataAll && (Array.isArray(dataAll) || Array.isArray(dataAll?.list) || Array.isArray(dataAll?.data))) {
+        return Array.isArray(dataAll) ? dataAll : (dataAll?.list ?? dataAll?.data ?? []);
+      }
+      // 兜底:读取 getByTree(需要 caseId,调用方应传入)
+      return [];
     } catch (e) {
       return [];
     }
@@ -166,16 +202,8 @@ export const getCaseFileTypesOffline = async (): Promise<any[]> => {
 
 export const getCaseFilesOffline = async (props: { caseId: number; filesTypeId?: number; }): Promise<any[]> => {
   if (isOfflineMode()) {
-    const base = getOfflineDataPath();
-    const caseId = props?.caseId;
-    const typeId = props?.filesTypeId ?? 'all';
-    const candidates = [
-      `${base}/case-files/${caseId}-${typeId}.json`,
-      `${base}/case-${caseId}-files-${typeId}.json`,
-      `${base}/files/${caseId}/${typeId}.json`,
-    ];
     try {
-      const data: any = await fetchOfflineJson<any>(candidates);
+      const data: any = await offlineByDataJson<any>(caseFiles, props);
       return Array.isArray(data) ? data : (data?.list ?? data?.data ?? []);
     } catch (e) {
       return [];
@@ -184,17 +212,52 @@ export const getCaseFilesOffline = async (props: { caseId: number; filesTypeId?:
   return (await axios.get(caseFiles, { params: props })).data as any[];
 };
 
+// =============== 文件详情:图片信息 / 勘验 / 提取清单(离线支持) ===============
+// 注意:后端接口在不同文件类型下返回结构可能不同:
+// - 记录板/图片制卷:{ content: string(JSON) }
+// - 勘验详情:{ inquest: {...} }
+// - 提取清单:{ extractDetail: {...} }
+// 这里统一兼容,若有 content 为字符串则尝试 JSON.parse。
+export const getCaseFileImageInfo = async (filesId: number): Promise<any> => {
+  if (!filesId) return null;
+  if (isOfflineMode()) {
+    try {
+      const data: any = await offlineByDataJson<any>(caseFileInfo, { filesId });
+      if (data && typeof data === 'object' && typeof (data as any).content === 'string') {
+        try {
+          return { ...data, content: JSON.parse((data as any).content) };
+        } catch {
+          // 解析失败则原样返回
+          return data;
+        }
+      }
+      return data;
+    } catch (e) {
+      return null;
+    }
+  }
+  try {
+    const res = await axios.get(caseFileInfo, { params: { filesId } });
+    const data = res?.data;
+    if (data && typeof data === 'object' && typeof (data as any).content === 'string') {
+      try {
+        return { ...data, content: JSON.parse((data as any).content) };
+      } catch {
+        return data;
+      }
+    }
+    return data;
+  } catch (e) {
+    return null;
+  }
+};
+
 // =============== 基础信息:案件信息(离线支持) ===============
 export const getCaseInfoOffline = async (caseId: number): Promise<any> => {
   if (isOfflineMode()) {
-    const base = getOfflineDataPath();
-    const candidates = [
-      `${base}/case-info/${caseId}.json`,
-      `${base}/case-${caseId}-info.json`,
-      `${base}/case/${caseId}.json`,
-    ];
     try {
-      const data: any = await fetchOfflineJson<any>(candidates);
+      const data: any = await offlineByDataJson<any>(caseInfo, { caseId });
+      console.log(data, 111)
       if (data && typeof data === 'object') {
         if (data.caseId) return data;
         return data.info ?? data.data ?? data.detail ?? data.value ?? data;
@@ -210,14 +273,8 @@ export const getCaseInfoOffline = async (caseId: number): Promise<any> => {
 // =============== 场景:案件场景列表(离线支持) ===============
 export const getCaseSceneListOffline = async (caseId: number): Promise<any[]> => {
   if (isOfflineMode()) {
-    const base = getOfflineDataPath();
-    const candidates = [
-      `${base}/case-scene-list/${caseId}.json`,
-      `${base}/case-${caseId}-scene-list.json`,
-      `${base}/scene/case-${caseId}.json`,
-    ];
     try {
-      const data: any = await fetchOfflineJson<any>(candidates);
+      const data: any = await offlineByDataJson<any>(caseSceneList, { caseId });
       return Array.isArray(data) ? data : (data?.list ?? data?.data ?? []);
     } catch (e) {
       return [];
@@ -297,14 +354,8 @@ export const getMix3dPaggingOffline = async (params: ScenePaggingParams): Promis
 // =============== 现场勘验:照片制卷列表(离线支持) ===============
 export const getFfmpegImageListOffline = async (caseId: number): Promise<any[]> => {
   if (isOfflineMode()) {
-    const base = getOfflineDataPath();
-    const candidates = [
-      `${base}/photos/${caseId}.json`,
-      `${base}/ffmpeg-images-${caseId}.json`,
-      `${base}/case-${caseId}-photos.json`,
-    ];
     try {
-      const data: any = await fetchOfflineJson<any>(candidates);
+      const data: any = await offlineByDataJson<any>(getFfmpegImage, { caseId });
       return Array.isArray(data) ? data : (data?.list ?? data?.data ?? []);
     } catch (e) {
       return [];

+ 12 - 1
src/store/permission.ts

@@ -3,6 +3,7 @@ import { ref } from "vue";
 import { findRoute, RouteName, Routes } from "@/router";
 import { pubPermissions } from "@/constant/permission";
 import { DataScope, RoleLevel, RoleMenuTreeItem, getRole } from "./role";
+import { isOfflineMode } from "@/util/offline";
 
 export type UserPermission = {
   resourceKey: string;
@@ -23,7 +24,17 @@ export const getPermissionRoutes = (routeNames: string[]) => {
   // permission.value.push({resourceKey: 'dyManager', type: 'menu', dataScope: 1})
   // permission.value.push({resourceKey: 'mix3dManager', type: 'menu', dataScope: 1})
   console.log('permission.value',permission.value)
-  return routeNames.filter((routeName) => permission.value.some((p) => p.resourceKey === routeName)).map((routeName) => findRoute(routeName)).filter((route) => route) as Routes;
+  // 离线包模式下,放开所有路由
+  if (isOfflineMode()) {
+    return routeNames
+      .map((routeName) => findRoute(routeName))
+      .filter((route) => route) as Routes;
+  }
+  // 在线模式按权限过滤
+  return routeNames
+    .filter((routeName) => permission.value.some((p) => p.resourceKey === routeName))
+    .map((routeName) => findRoute(routeName))
+    .filter((route) => route) as Routes;
 };
 
 export const setPermission = (perms: UserPermission[]) => {

+ 5 - 6
src/util/offline.ts

@@ -1,8 +1,7 @@
 export const isOfflineMode = (): boolean => {
   try {
-    const envFlag = (import.meta as any)?.env?.VITE_OFFLINE;
-    const winFlag = (typeof window !== 'undefined') && (window as any).__OFFLINE_MODE__;
-    return String(envFlag).toLowerCase() === 'true' || !!winFlag;
+    const envFlag = import.meta.env.VITE_OFFLINE;
+    return envFlag === 'true' || envFlag;
   } catch (e) {
     return false;
   }
@@ -10,10 +9,10 @@ export const isOfflineMode = (): boolean => {
 
 export const getOfflineDataPath = (): string => {
   try {
-    const p = (import.meta as any)?.env?.VITE_OFFLINE_DATA_PATH;
-    return p || '/offline-data';
+    const p = import.meta.env.VITE_OFFLINE_DATA_PATH;
+    return p || './';
   } catch (e) {
-    return '/offline-data';
+    return './';
   }
 };
 

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

@@ -65,6 +65,7 @@ const stateTitle = {
 
 const params = {
   caseId: props.caseId,
+  fromRoute: 'fire'
 };
 // 初始化
 const initial = async () => {

+ 1 - 1
src/view/newFireCase/newFireDetails/components/headerTop.vue

@@ -48,7 +48,7 @@ const props = defineProps<{
   editOrShow: string;
   showSave?: boolean;
 }>();
-const appId = import.meta.env.VITE_APP_APP || 'fire'
+const appId = import.meta.env.VITE_APP_APP === 'criminal' ? 'criminal' : !import.meta.env.VITE_APP_APP ? '' : 'fire'
 const route = useRoute();
 const vueRouter = useRouter();
 const typeName = computed(() => {

+ 13 - 3
src/view/newFireCase/newFireDetails/components/mix3d.vue

@@ -44,7 +44,7 @@
       </div>
      <div class="right-bar">
       <div v-if="activeScene?.fusionId" class="iframe-container">
-        <iframe :key="activeNum" :src="`${url}/code/index.html?caseId=${activeScene?.fusionId}&pure=1&token=${user?.token}#/show/summary`" width="96%" height="96%" frameborder="0"></iframe>
+        <iframe :key="activeNum" :src="activeWebSite" width="96%" height="96%" frameborder="0"></iframe>
       </div>
       <div v-else class="iframe-container-nodata">
         暂无数据
@@ -121,7 +121,7 @@
   </div>
 </template>
 <script setup lang="ts">
-import { ref, computed, onMounted, nextTick } from "vue";
+import { ref, computed, onMounted, nextTick, watch } from "vue";
 import { useRoute, useRouter } from 'vue-router';
 import { Scene } from '@/store/scene';
 import { SceneTypeDesc } from '@/constant/scene';
@@ -129,6 +129,7 @@ import { getFusionAndSceneList, addMix3dFusionIds, getMix3dPaggingOffline as get
 import { confirm } from '@/helper/message';
 import { ElMessage } from 'element-plus';
 import { user } from "@/store/user";
+import { isOfflineMode } from '@/util/offline'
 const url = import.meta.env.VITE_SEVER_URL || 'https://mix3d.4dkankan.com';
 
 const route = useRoute();
@@ -142,7 +143,16 @@ const props = defineProps<{ fire?: any, caseId?: number, editOrShow?: string }>(
 const activeScene = computed(() => {
   return scenes.value.find((s: any) => String(s?.fusionId) === activeId.value) || null;
 });
-
+const activeWebSite = ref('');
+watch(activeId, (newVal, oldVal) => {
+  if (newVal !== oldVal) {
+    if (isOfflineMode()) {
+      activeWebSite.value = `./fusion_offline/${newVal}/www/offline.html?caseId=${newVal}&pure=1#/show/summary`;
+    } else {
+      activeWebSite.value = `${url}/code/index.html?caseId=${activeScene.value?.fusionId}&pure=1&token=${user.value.token}#/show/summary`;
+    }
+  }
+}, { immediate: true })
 const activeNum = computed(() => (activeScene.value as any)?.num || '');
 // 编辑弹窗相关
 const editVisible = ref(false);

+ 18 - 7
src/view/newFireCase/newFireDetails/components/otherFiles.vue

@@ -64,12 +64,12 @@
   </div>
   
   <!-- 图片全屏预览(查看非右侧区域时用) -->
-  <!-- <el-image-viewer
+  <el-image-viewer
     v-if="showViewer"
-    :url-list="[previewUrl!]"
+    :url-list="[previewUrl]"
     :initial-index="0"
     @close="showViewer = false"
-  /> -->
+  />
 </template>
 
 <script setup lang="ts">
@@ -81,6 +81,7 @@ import { useUpload } from '@/hook/upload';
 import { addCaseFile, setCaseFile, delCaseFile, type CaseFile } from '@/store/caseFile';
 import { getCaseFileTypesOffline as getCaseFileTypes, getCaseFilesOffline as getCaseFiles } from '@/store/editCsae';
 import { confirm } from '@/helper/message';
+import { isOfflineMode } from '@/util/offline';
 
 const props = defineProps<{ caseId?: number; fire?: any, editOrShow?: string }>();
 const route = useRoute();
@@ -127,13 +128,23 @@ const handleUploadRequest = async (options: any) => {
 const handleView = (file: CaseFile) => {
   const ext = file.filesUrl.substring(file.filesUrl.lastIndexOf('.')).toLocaleLowerCase();
   const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
+  const toOfflineAbs = (raw: string) => {
+    const httpHost = /^https?:\/\/[^/]+/i;
+    if (httpHost.test(raw)) return '/' + raw.replace(httpHost, '').replace(/^\//, '');
+    if (raw.startsWith('/')) return raw;          // already absolute '/...'
+    if (raw.startsWith('./')) return '/' + raw.slice(2);
+    return '/' + raw;                             // 'fusion/...' -> '/fusion/...'
+  };
+  const viewUrl = isOfflineMode() ? toOfflineAbs(file.filesUrl) : file.filesUrl;
   if (imageExts.includes(ext)) {
-    previewUrl.value = file.filesUrl;
-    showViewer.value = false;
+    previewUrl.value = viewUrl;
+    showViewer.value = true;
   } else if ([".raw", ".dcm"].includes(ext)) {
-    window.open(`/${appId}/xfile-viewer/index.html?file=${file.filesUrl}&name=${file.filesTitle}&time=` + Date.now());
+    const fileParam = encodeURIComponent(viewUrl);
+    const nameParam = encodeURIComponent(file.filesTitle || '');
+    window.open(`/${appId}/xfile-viewer/index.html?file=${fileParam}&name=${nameParam}&time=` + Date.now());
   } else {
-    window.open(file.filesUrl + '?time=' + Date.now());
+    window.open(viewUrl + '?time=' + Date.now());
   }
 };
 

+ 9 - 1
src/view/newFireCase/newFireDetails/components/scene.vue

@@ -158,6 +158,7 @@ import { SceneTypeDesc } from '@/constant/scene';
 import { getCaseScenes, replaceCaseScenes, getSceneKey, getCaseScenesBySceneType } from '@/store/case';
 import { confirm } from '@/helper/message';
 import { ElMessage } from 'element-plus';
+import { isOfflineMode } from '@/util/offline'
 
 const route = useRoute();
 const router = useRouter();
@@ -174,7 +175,14 @@ watch(activeId, (newVal, oldVal) => {
   if (newVal !== oldVal) {
     const scene = scenes.value.find((s: any) => String(s?.sceneNumId) === activeId.value);
     if (scene) {
-      activeWebSite.value = scene.webSite || '';
+      // 离线模式
+      if (isOfflineMode()) {
+        let filename = [0, 1, 3, 4, 6].includes(Number(scene.sceneType)) ? 'swkk' : 'swss';
+        let HtmlName = [0, 1, 3, 4, 6].includes(Number(scene.sceneType)) ? 'spg.html' : 'offline.html';
+        activeWebSite.value = `./${filename}/${scene.num}/wwwroot/${HtmlName}?m=${scene.num}`
+      } else {
+        activeWebSite.value = scene.webSite || '';
+      }
     }
   }
 })

+ 12 - 1
src/view/newFireCase/newFireDetails/components/screenShot.vue

@@ -64,6 +64,7 @@ import { ref, computed, watch } from "vue";
 import { useRoute, useRouter } from 'vue-router';
 import { getRecordCaseVideo, getUploadRecordProgress } from '@/store/editCsae';
 import { getResource, baseURL } from '@/store/system';
+import { isOfflineMode } from '@/util/offline';
 import { saveAs } from '@/util/file-serve';
 import { axios, deleteRecord as deleteRecordUrl } from '@/request';
 import { ElMessage, ElMessageBox } from 'element-plus';
@@ -160,7 +161,17 @@ const handleView = (file: any) => {
   ].filter(Boolean);
   const rawUrl = candidates[0];
   if (!rawUrl) return;
-  const url = typeof rawUrl === 'string' ? getResource(rawUrl) : rawUrl;
+  const url = typeof rawUrl === 'string'
+    ? (isOfflineMode()
+        ? (() => {
+            const httpHost = /^https?:\/\/[^/]+/i;
+            if (httpHost.test(rawUrl)) return '.' + rawUrl.replace(httpHost, '');
+            if (rawUrl.startsWith('/')) return '.' + rawUrl; // '/fusion/...' -> './fusion/...'
+            if (rawUrl.startsWith('./')) return rawUrl;      // already relative './...'
+            return './' + rawUrl;                            // 'fusion/...' -> './fusion/...'
+          })()
+        : getResource(rawUrl))
+    : rawUrl;
   emit('playVideo', url);
 };
 // 继续录制:将当前文件信息抛到上层以便进入录制弹窗

+ 77 - 71
src/view/newFireCase/newFireDetails/components/siteInspection.vue

@@ -280,7 +280,7 @@
               </div>
               <div v-else class="records-preview">
                 <h3 class="title">上传文件</h3>
-                <pre class="preview-text">{{ inquestData.filesTitle || '-' }}</pre>
+                <div class="preview-text" @click="openInquestFile(inquestData)">{{ inquestData.filesTitle || '-' }}</div>
               </div>
               <el-image-viewer
                 v-if="showViewer"
@@ -310,7 +310,7 @@
               </div>
               <div v-else class="records-preview">
                 <h3 class="title">上传文件</h3>
-                <pre class="preview-text">{{ extractionData.filesTitle || '-' }}</pre>
+                <div class="preview-text" @click="openInquestFile(inquestData)">{{ extractionData.filesTitle || '-' }}</div>
               </div>
               <el-image-viewer
                 v-if="showViewer"
@@ -331,7 +331,7 @@
         <!-- 照片卷右侧:单图预览,可点击放大 -->
         <template v-else-if="activeTab === 'album'">
           <div v-if="currentAlbum" class="scene-image" @click="openAlbumViewer(0)">
-            <el-image :src="currentAlbum.images[0].url" fit="contain" class="inline-image" />
+            <el-image :src="currentAlbumImageUrl" fit="contain" class="inline-image" />
           </div>
           <div v-else class="viewer-placeholder">暂无照片卷</div>
           <el-image-viewer
@@ -352,7 +352,8 @@ import RelName from './relName.vue';
 import SiteText from './sitetext.vue';
 import ManifestText from './manifesttext.vue';
 // 弹窗已移除,直接跳转到绘制页面
-import { CaseFile, BoardType, setCaseFile, delCaseFile, getCaseFileImageInfo } from '@/store/caseFile';
+import { CaseFile, BoardType, setCaseFile, delCaseFile } from '@/store/caseFile';
+import { getCaseFileImageInfo } from '@/store/editCsae';
 import { FileDrawType } from '@/constant/caseFile';
 import { addCaseFile as addCaseFileDialog } from '@/view/case/quisk';
 import { router, RouteName } from '@/router';
@@ -361,6 +362,7 @@ import { exportCaseInquestInfo, exportCaseDetailInfo, caseDel } from '@/store/ca
 import { getFfmpegImageListOffline as getFfmpegImageList, caseFilesTypeGetTree, getCaseFilesOffline as getCaseFiles } from '@/store/editCsae';
 import { getCaseInquestInfo, getCaseDetailInfo } from '@/store/case';
 import { axios } from '@/request';
+import { isOfflineMode } from '@/util/offline';
 import { saveAs } from '@/util/file-serve';
 
 const appId = import.meta.env.VITE_APP_APP || 'fire';
@@ -434,7 +436,7 @@ const loadListsFromTree = async () => {
       id: Number(item?.filesId),
       ...item,
       title: item?.title || (item?.count ? `勘验笔录(第${item.count}次)` : `勘验笔录 ${idx + 1}`),
-      content: typeof item?.content === 'string' ? item.content : (item?.startTime || item?.endTime ? formatInquest(item) : ''),
+      content: typeof item?.content === 'string' ? item.content : '',
     }));
     if (inspectionList.value.length) {
       selectedInquestId.value = inspectionList.value[0]?.id ?? null;
@@ -455,7 +457,7 @@ const loadListsFromTree = async () => {
       id: Number(item?.filesId),
       ...item,
       title: item?.title || (item?.address ? `提取清单(${item.address})` : `提取清单 ${idx + 1}`),
-      content: typeof item?.content === 'string' ? item.content : formatExtraction(item),
+      content: typeof item?.content === 'string' ? item.content : '',
     }));
     if (extractionList.value.length) {
       selectedExtractId.value = extractionList.value[0]?.id ?? null;
@@ -500,19 +502,62 @@ const viewerIndex = ref(0);
 // 现场图选择高亮索引
 const selectedPlaneIndex = ref<number | null>(null);
 const selectedOrientationIndex = ref<number | null>(null);
+const toOffline = (raw: string) => {
+    if (!raw) return '';
+    const httpHost = /^https?:\/\/[^/]+/i;
+    if (httpHost.test(raw)) return '.' + raw.replace(httpHost, '');
+    if (raw.startsWith('/')) return '.' + raw;
+    if (raw.startsWith('./')) return raw;
+    return './' + raw;
+  };
 // 右侧展示的现场图当前图片
 const currentSceneImageUrl = computed(() => {
+
+  // 离线模式:优先使用原始文件的 filesUrl 并转换为相对路径
+  if (isOfflineMode()) {
+    if (selectedPlaneIndex.value !== null && planeFiles.value[selectedPlaneIndex.value]) {
+      const raw = (planeFiles.value[selectedPlaneIndex.value] as any)?.filesUrl || (planeImages.value[selectedPlaneIndex.value] as any)?.url;
+      return toOffline(String(raw || ''));
+    }
+    if (selectedOrientationIndex.value !== null && orientationFiles.value[selectedOrientationIndex.value]) {
+      const raw = (orientationFiles.value[selectedOrientationIndex.value] as any)?.filesUrl || (orientationImages.value[selectedOrientationIndex.value] as any)?.url;
+      return toOffline(String(raw || ''));
+    }
+    return '';
+  }
+
+  // 在线模式:保持原逻辑使用已映射的图片 url
   if (selectedPlaneIndex.value !== null && planeImages.value[selectedPlaneIndex.value]) {
     return planeImages.value[selectedPlaneIndex.value].url;
   }
-  if (
-    selectedOrientationIndex.value !== null &&
-    orientationImages.value[selectedOrientationIndex.value]
-  ) {
+  if (selectedOrientationIndex.value !== null && orientationImages.value[selectedOrientationIndex.value]) {
     return orientationImages.value[selectedOrientationIndex.value].url;
   }
   return '';
 });
+const openInquestFile = (item: any) => {
+  if (item?.filesUrl) {
+    if (isOfflineMode()) {
+      window.open(toOffline(item.filesUrl), '_blank');
+    } else {
+      window.open(item.filesUrl, '_blank');
+    }
+  }
+};
+// 照片卷当前图片链接,兼容离线模式
+const currentAlbumImageUrl = computed(() => {
+  const img = currentAlbum.value?.images?.[0];
+  const raw = img?.url || '';
+  if (!raw) return '';
+  if (isOfflineMode()) {
+    const httpHost = /^https?:\/\/[^/]+/i;
+    if (httpHost.test(raw)) return '.' + raw.replace(httpHost, '');
+    if (raw.startsWith('/')) return '.' + raw;
+    if (raw.startsWith('./')) return raw;
+    return './' + raw;
+  }
+  return raw;
+});
 
 const openViewer = (type: 'plane' | 'orientation', index: number) => {
   const isPlane = type === 'plane';
@@ -738,7 +783,7 @@ const loadInspection = async (filesId?: number) => {
       if (idx >= 0) {
         inquestRawList.value[idx] = detail;
         inquestData.value = detail || {};
-        inspectionList.value[idx].content = formatInquest(detail || {});
+        inspectionList.value[idx].content = typeof (detail as any)?.content === 'string' ? (detail as any).content : '';
       } else {
         // 如果未找到索引,仅更新右侧数据
         inquestData.value = res || {};
@@ -754,14 +799,14 @@ const loadInspection = async (filesId?: number) => {
         id: Number(item?.id),
         ...item,
         title: item?.count ? `勘验笔录(第${item.count}次)` : `勘验笔录 ${idx + 1}`,
-        content: formatInquest(item),
+        content: typeof item?.content === 'string' ? item.content : '',
       }));
       if (inspectionList.value.length) {
         selectedInquestId.value = inspectionList.value[0]?.id ?? null;
         inquestData.value = inquestRawList.value[0] || {};
       }
     } else {
-      const content = formatInquest(payload || {});
+      const content = typeof (payload as any)?.content === 'string' ? (payload as any).content : '';
       inquestRawList.value = [payload || {}];
       if (!inspectionList.value.length) {
         inspectionList.value = [{ id: Number((payload as any)?.id), title: '勘验笔录', content }];
@@ -779,28 +824,7 @@ const loadInspection = async (filesId?: number) => {
     console.error('获取勘验笔录失败:', e);
   }
 };
-// 将接口数据格式化为预览文本
-const formatInquest = (d: any) => {
-  const s = d.startTime || {}; const e = d.endTime || {};
-  const witnesses = Array.isArray(d.witnessInfo) ? d.witnessInfo.map((w: any, i: number) => `证人${i+1}:${w.name || '-'},${w.year || ''}年${w.month || ''}月${w.day || ''}日\n身份证件号码:${w.id || '-'}\n单位或住址:${w.address || '-'}\n`).join('\n') : '';
-  return (
-    `勘验次数: 第 ${d.count || '-'} 次勘验\n` +
-    `勘验时间: ${s.year || ''}年${s.month || ''}月${s.day || ''}日 ${s.hour || ''}时${s.min || ''}分 至 ${e.year || ''}年${e.month || ''}月${e.day || ''}日 ${e.hour || ''}时${e.min || ''}分\n` +
-    `勘验地点: ${d.address || '-'}\n` +
-    `勘验人员姓名、勘验人描述(含技术员职务): ${d.userInfo || '-'}\n` +
-    `勘验气象条件(天空、风力、温度): ${d.weather || '-'}\n\n` +
-    `勘验情况: \n${d.situation || ''}\n\n` +
-    `一、环境勘验\n${d.environment || ''}\n\n` +
-    `二、初步勘验\n${d.firstInquest || ''}\n\n` +
-    `三、细项勘验\n${d.carefulInquest || ''}\n\n` +
-    `四、专项勘验\n${d.specialInquest || ''}\n\n` +
-    `提取物品描述:\n${d.itemDescription || ''}\n\n` +
-    `现场拍照制图描述:\n${d.imgDescription || ''}\n\n` +
-    `勘验信息:\n` +
-    `勘验负责人:${d.leader || '-'}\n记录人:${d.recorder || '-'}\n勘验人:${d.inspector || '-'}\n\n` +
-    witnesses
-  );
-};
+// 旧的文本格式化逻辑已由组件渲染替代,移除无用函数。
 
 // 调取接口并填充列表(提取清单)
 const loadExtraction = async (filesId?: number) => {
@@ -815,7 +839,7 @@ const loadExtraction = async (filesId?: number) => {
         extractionRawList.value[idx] = detail;
         extractionData.value = detail || {};
         const prev = extractionList.value[idx] || { title: '提取清单' };
-        extractionList.value[idx].content = formatExtraction(detail || {});
+        extractionList.value[idx].content = typeof (detail as any)?.content === 'string' ? (detail as any).content : '';
       } else {
         extractionData.value = res;
       }
@@ -828,7 +852,7 @@ const loadExtraction = async (filesId?: number) => {
       extractionList.value = payload.map((item: any, idx: number) => ({
         id: Number(item?.id ?? item?.extractId),
         title: item?.address ? `提取清单(${item.address})` : `提取清单 ${idx + 1}`,
-        content: formatExtraction(item),
+        content: typeof item?.content === 'string' ? item.content : '',
       }));
       if (extractionList.value.length) {
         selectedExtractId.value = extractionList.value[0]?.id ?? null;
@@ -841,7 +865,7 @@ const loadExtraction = async (filesId?: number) => {
           (target as any)[k] = (payload as any)[k];
         }
       }
-      const content = formatExtraction(target);
+      const content = typeof (target as any)?.content === 'string' ? (target as any).content : '';
       if (!extractionList.value.length) {
         extractionList.value = [{ id: Number((payload as any)?.id ?? (payload as any)?.extractId), title: '提取清单', content }];
         selectedExtractId.value = extractionList.value[0]?.id ?? null;
@@ -876,38 +900,7 @@ const extractionData = ref<any>({
   ],
 });
 
-const formatExtraction = (d: any) => {
-  const time = d.time || {};
-  const header = `起火单位/地址: ${d.address || ''}\n提取日期: ${time.year || ''}年${time.month || ''}月${time.day || ''}日\n`;
-  const items = Array.isArray(d.detail)
-    ? d.detail
-        .map(
-          (item: any, idx: number) =>
-            `编号 ${idx + 1}:\n名称: ${item.name || ''}\n规格: ${item.spec || ''}\n数量: ${item.num || ''}\n提取部位: ${item.part || ''}\n特征: ${item.desc || ''}\n`
-        )
-        .join('\n')
-    : '';
-
-  const extractUsers = Array.isArray(d.extractUser)
-    ? d.extractUser
-        .map(
-          (u: any) => `提取人:姓名: ${u.name || ''}  工作单位: ${u.address || ''}`
-        )
-        .join('\n')
-    : '';
-
-  const witnesses = Array.isArray(d.witnessInfo)
-    ? `\n证人或当事人:\n` +
-      d.witnessInfo
-        .map(
-          (w: any) =>
-            `姓名: ${w.name || ''}  身份证件号码: ${w.id || ''}  联系电话: ${w.phone || ''}\n单位或住址: ${w.address || ''}`
-        )
-        .join('\n\n')
-    : '';
-
-  return `${header}\n${items}\n${extractUsers}${witnesses ? '\n\n' + witnesses : ''}`.trim();
-};
+// 旧的文本格式化逻辑已由组件渲染替代,移除无用函数。
 
 const extractionList = ref<{ id?: number; title: string; content: string }[]>([]);
 const extractionRawList = ref<any[]>([]);
@@ -945,7 +938,20 @@ const currentAlbum = computed(() => albumList.value[selectedAlbumIndex.value]);
 const selectAlbum = (idx: number) => (selectedAlbumIndex.value = idx);
 const openAlbumViewer = (index: number) => {
   const list = currentAlbum.value?.images || [];
-  viewerUrls.value = list.map((i) => i.url);
+  const toOffline = (raw: string) => {
+    if (!raw) return '';
+    const httpHost = /^https?:\/\/[^/]+/i;
+    if (httpHost.test(raw)) return '.' + raw.replace(httpHost, '');
+    if (raw.startsWith('/')) return '.' + raw;
+    if (raw.startsWith('./')) return raw;
+    return './' + raw;
+  };
+  viewerUrls.value = list
+    .map((i) => {
+      const raw = i?.url || '';
+      return isOfflineMode() ? toOffline(String(raw)) : raw;
+    })
+    .filter((u) => !!u);
   viewerIndex.value = index;
   showViewer.value = true;
 };

+ 10 - 4
src/view/newFireCase/newFireDetails/index.vue

@@ -53,9 +53,10 @@ import {
   isTemploraryID,
   createTemploraryID,
 } from '@/store/system'
+import { isOfflineMode } from '@/util/offline'
 
 // 从路由获取参数
-const appId = import.meta.env.VITE_APP_APP === 'criminal' ? 'criminal' : 'fire'
+const appId = import.meta.env.VITE_APP_APP === 'criminal' ? 'criminal' : !import.meta.env.VITE_APP_APP ? '' : 'fire'
 const route = useRoute();
 const vueRouter = useRouter();
 const caseId = computed(() => Number(route.params.caseId));
@@ -90,9 +91,14 @@ const loadCaseInfo = async () => {
     console.error(error);
     const msg = typeof error === 'string' ? error : (error?.msg || error?.message || '');
     if (!msg || msg.includes('未登录') || msg.includes('失效') || msg.includes('token')) {
-      // 未登录或登录失效:跳转到登录页,并携带当前页作为重定向目标
-      vueRouter.replace({ name: RouteName.login, query: { redirect: route.fullPath } });
-      return;
+      // 离线模式下不跳转登录,直接留在当前页
+      if (isOfflineMode()) {
+        ElMessage.warning('离线模式:已跳过登录校验');
+      } else {
+        // 未登录或登录失效:跳转到登录页,并携带当前页作为重定向目标
+        vueRouter.replace({ name: RouteName.login, query: { redirect: route.fullPath } });
+        return;
+      }
     }
     // 其他错误:跳转到无权限/不存在页面
     vueRouter.replace({ name: RouteName.noCase, query: {} });

+ 1 - 1
vite.config.ts

@@ -21,7 +21,7 @@ export default ({ mode }: any) => {
           // 在这里继续添加更多页面
         },
       },
-      outDir: `dist/${mode}`,
+      outDir: mode === 'offline' ? `dist/offline` : `dist/${mode}`,
     },
     resolve: {
       alias: [