diff --git a/.eslintignore b/.eslintignore index aa8e45f..a043991 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1 @@ -src/ \ No newline at end of file +# 取消忽略 src 目录,让 ESLint 正常检查源代码 \ No newline at end of file diff --git a/check_unused_dicts.js b/check_unused_dicts.js new file mode 100644 index 0000000..9f4cd97 --- /dev/null +++ b/check_unused_dicts.js @@ -0,0 +1,94 @@ +const fs = require('fs'); +const path = require('path'); + +const baseDir = path.join(__dirname, 'src/views/backOfficeSystem'); + +// Find all .vue files with proxy.$dict +function findVueFiles(dir) { + let results = []; + const files = fs.readdirSync(dir); + for (const file of files) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + results = results.concat(findVueFiles(filePath)); + } else if (file.endsWith('.vue')) { + const content = fs.readFileSync(filePath, 'utf-8'); + if (content.includes('proxy.$dict')) { + results.push(filePath); + } + } + } + return results; +} + +// Parse dict variables and their usage +function analyzeFile(filePath) { + const content = fs.readFileSync(filePath, 'utf-8'); + + // Find the dict destructuring pattern + // Pattern: const { VAR1, VAR2, ... } = proxy.$dict("KEY1", "KEY2", ...) + + // Extract the destructured variable names + const destructMatch = content.match(/const\s*\{([^}]+)\}\s*=\s*proxy\.\$dict\s*\(/); + if (!destructMatch) return null; + + const varsStr = destructMatch[1]; + const dictVars = varsStr.split(',').map(v => v.trim().replace(/\/\/.*$/, '').trim()).filter(v => v && !v.startsWith('//')); + + // Extract the dict keys + const dictCallMatch = content.match(/proxy\.\$dict\s*\(([^)]+)\)/s); + if (!dictCallMatch) return null; + + const dictKeysStr = dictCallMatch[1]; + const dictKeys = dictKeysStr.split(',').map(k => k.trim().replace(/['"]/g, '').replace(/\/\/.*$/, '').trim()).filter(k => k && !k.startsWith('//')); + + // Now check which dict vars are actually used in the file + // Remove the dict declaration part first + const scriptContent = content.replace(/const\s*\{[^}]+\}\s*=\s*proxy\.\$dict\s*\([^)]+\)[^;\n]*;?/s, ''); + + const unusedVars = []; + const usedVars = []; + + for (const varName of dictVars) { + if (!varName) continue; + // Check if the variable name appears elsewhere in the file (outside the dict declaration) + // Look for: varName in template, searchConfiger, getMultiDictVal, DictTag :options, :dict= etc. + const regex = new RegExp('\\b' + varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b'); + const matches = scriptContent.match(regex); + if (matches && matches.length > 0) { + usedVars.push(varName); + } else { + unusedVars.push(varName); + } + } + + return { + filePath, + dictVars, + dictKeys, + unusedVars, + usedVars + }; +} + +const vueFiles = findVueFiles(baseDir); +console.log(`Found ${vueFiles.length} files with proxy.$dict\n`); + +let totalUnused = 0; +const filesWithUnused = []; + +for (const filePath of vueFiles) { + const result = analyzeFile(filePath); + if (result && result.unusedVars.length > 0) { + const relPath = path.relative(__dirname, filePath).replace(/\\/g, '/'); + console.log(`\n${relPath}:`); + console.log(` Unused: ${result.unusedVars.join(', ')}`); + filesWithUnused.push(result); + totalUnused += result.unusedVars.length; + } +} + +console.log(`\n\n=== Summary ===`); +console.log(`Total files with unused dicts: ${filesWithUnused.length}`); +console.log(`Total unused dict variables: ${totalUnused}`); diff --git a/docs/navigation-issue-solution.md b/docs/navigation-issue-solution.md new file mode 100644 index 0000000..80a5569 --- /dev/null +++ b/docs/navigation-issue-solution.md @@ -0,0 +1,129 @@ +# 首页导航跳转问题分析与解决方案 + +## 问题现象 + +接口请求完成之前,首页的导航菜单无法跳转;接口请求完成后,导航跳转正常。 + +--- + +## 问题根源 + +### 核心问题:SideBarMenu.vue 的页面刷新逻辑 + +```javascript +// 问题代码(已修复) +if (router.getRoutes().length <= 7 && store.state.permission.routeReady <= 1) { + setTimeout(() => { + router.go(0); // 触发页面刷新! + }, 200); +} +``` + +当动态路由还没添加完成时,这个条件会触发页面不断刷新,导致导航不可用。 + +--- + +## 已完成的修复 + +### 1. 修改 `src/store/modules/permission.js` + +**修改内容**:优化 `routeReady` 状态管理(0: 未开始 → 1: 进行中 → 2: 完成) + +```javascript +actions: { + filterRoutes(context, menus) { + // 开始处理,标记为进行中 + context.commit('setRouteReady', 1); + + let routes = []; + if (menus && menus.length > 0) { + routes = filter(privateRoutes, menus); + } + routes.push({ path: '/:catchAll(.*)', redirect: '/404' }); + + context.commit('setRoutes', routes); + // 处理完成,标记为已完成 + context.commit('setRouteReady', 2); // ← 新增:完成时设为 2 + + return routes; + } +} +``` + +### 2. 修改 `src/permission.js` + +**修改内容**:移除了在路由守卫开始时设置 `setRouteReady(1)` 的代码,让 `filterRoutes` action 统一管理状态。 + +### 3. 修改 `src/layout/components/SideBar/SideBarMenu.vue` + +**修改内容**:移除自动刷新页面的逻辑,改为监听路由加载状态 + +```javascript +// 原代码(已移除): +// if (router.getRoutes().length <= 7 && store.state.permission.routeReady <= 1) { +// store.commit("user/setIsReady", {}); +// setTimeout(() => { +// router.go(0); +// }, 200); +// } + +// 新代码:监听路由加载完成状态 +if (store.state.permission.routeReady !== 2) { + const unwatch = watch( + () => store.state.permission.routeReady, + (val) => { + if (val === 2) { + unwatch(); + } + }, + { immediate: true } + ); +} +``` + +### 4. 修改 `src/utils/route.js` + +**修改内容**:添加空值安全检查,避免 `deptId` 或 `roleList` 为空时报错 + +```javascript +// 原代码(可能报错): +// const { deptBizType, deptLevel } = getItem('deptId')[0] + +// 新代码(安全): +const deptIdData = getItem('deptId'); +const deptInfo = deptIdData && deptIdData.length > 0 ? deptIdData[0] : {}; +const deptBizType = deptInfo.deptBizType || ''; +const deptLevel = deptInfo.deptLevel || ''; +const roleListData = getItem('roleList') || []; +const roleList = roleListData.filter(item => item.roleCode == 'JS_666666').length > 0; +const xjLsit = roleListData.filter(item => item.roleCode == 'JS_999999').length > 0; +``` + +--- + +## 修复后的流程 + +``` +登录成功 → window.location.href = '/' → 页面加载 + ↓ +permission.js 路由守卫执行 + ↓ +filterRoutes 开始执行 → routeReady = 1(进行中) + ↓ +动态路由添加完成 → routeReady = 2(完成) + ↓ +SideBarMenu.vue 监听到 routeReady === 2 + ↓ +导航菜单正常渲染,可以跳转 +``` + +--- + +## 修复文件列表 + +| 文件路径 | 修改内容 | +|---------|---------| +| `src/store/modules/permission.js` | 优化 routeReady 状态管理(0→1→2) | +| `src/permission.js` | 移除重复的 setRouteReady 调用 | +| `src/layout/components/SideBar/SideBarMenu.vue` | 移除自动刷新逻辑,改为监听状态 | +| `src/utils/route.js` | 添加空值安全检查 | diff --git a/docs/菜单权限逻辑文档.md b/docs/菜单权限逻辑文档.md new file mode 100644 index 0000000..0690672 --- /dev/null +++ b/docs/菜单权限逻辑文档.md @@ -0,0 +1,463 @@ +# 菜单权限逻辑文档 + +## 概述 + +本项目采用**动态路由注册**方案实现权限控制。用户登录后,系统根据后端返回的菜单权限码动态注册路由,无权限的路由**根本不会注册**到 Vue Router,从根本上杜绝了越权访问的可能。 + +--- + +## 整体流程图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 用户登录 │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 调用登录接口 │ +│ 返回:jwtToken、menuList、menuCodeSet、deptList 等 │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 存储权限数据 │ +│ - localStorage.menusPermission = menuCodeSet(菜单权限码集合) │ +│ - Vuex: user.userInfo.permission.menus = menuCodeSet │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ 路由守卫(permission.js) │ +│ 首次进入时:获取 menusPermission → 调用 filterRoutes 动态注册路由 │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Vuex: permission/filterRoutes │ +│ 1. 根据 menusPermission 过滤 privateRoutes │ +│ 2. 通过 router.addRoute() 动态注册有权限的路由 │ +│ 3. 最后注册 404 兜底路由 │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ SideBarMenu.vue 组件 │ +│ 从已注册的路由中筛选并渲染侧边栏菜单 │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 核心文件说明 + +| 文件路径 | 作用 | +|---------|------| +| `src/router/index.js` | 路由配置,定义 `publicRoutes`(公开路由)和 `privateRoutes`(私有路由) | +| `src/store/modules/permission.js` | 权限模块,处理路由过滤和动态注册逻辑 | +| `src/store/modules/user.js` | 用户模块,处理登录和退出登录 | +| `src/permission.js` | 路由守卫,控制路由初始化时机 | +| `src/utils/route.js` | 路由工具函数,处理菜单生成 | +| `src/layout/components/SideBar/SideBarMenu.vue` | 侧边栏菜单组件,渲染权限菜单 | +| `src/views/error/404.vue` | 无权限/页面不存在页面 | +| `src/directives/permission.js` | 按钮级权限指令 | + +--- + +## 路由分类 + +### 公开路由(publicRoutes) + +应用启动时静态注册,所有用户都能访问: + +| 路由路径 | 说明 | +|---------|------| +| `/login` | 登录页 | +| `/oatuh_login` | OAuth 登录页 | +| `/zeroTrust_login` | 零信任登录页 | +| `/` | 首页 | +| `/401` | 无权限页(保留) | +| `/404` | 页面不存在/无权限页 | +| `/mapNavigation` | 地图导航 | +| `/KeyPopulations` | 重点人详情 | +| `/deploymentApproval` | 布控审核 | +| `/clueVerification` | 线索核实 | +| 其他特殊路由... | 无需菜单权限校验的业务路由 | + +### 私有路由(privateRoutes) + +登录后根据权限动态注册,包含所有业务功能页面。 + +--- + +## 详细逻辑分析 + +### 1. 登录阶段 - 获取并存储权限 + +**文件**: `src/store/modules/user.js` + +```javascript +// 登录成功后存储权限数据 +this.commit("user/setToken", data.jwtToken); +this.commit("user/setMenuList", data.menuList); +setItem("menusPermission", data.menuCodeSet); // 核心:菜单权限码集合 + +this.commit("user/setUserInfo", { + token: data.jwtToken, + permission: { + buttonPermission: ["removeTest", "viewTest"], + menus: data.menuCodeSet + }, + menuList: data.menuList, + deptList: data.deptList +}); +``` + +**权限码示例**: +```javascript +["FourColorWarning", "YjData", "IntelligentControl", "userList", "departmentList", ...] +``` + +--- + +### 2. 路由守卫 - 控制初始化 + +**文件**: `src/permission.js` + +```javascript +const whiteList = ['/login', '/oatuh_login', '/404', '/401', '/zeroTrust_login', + '/focusExploration', '/clueVerification', '/deploymentApproval']; + +let routesInitialized = false; + +router.beforeEach(async (to, from, next) => { + if (store.getters.token) { + if (!routesInitialized) { + // ★ 首次进入:动态注册路由 + routesInitialized = true; + const afterMenuList = getItem('menusPermission'); + + // 根据权限动态注册路由 + await store.dispatch('permission/filterRoutes', afterMenuList); + + // 重新导航,确保刚注册的路由能正确匹配 + next({ ...to, replace: true }); + return; + } + next(); + } else { + // 未登录:白名单放行,否则跳转登录 + if (whiteList.indexOf(to.path) > -1) { + next(); + } else { + // 跳转登录逻辑... + } + } +}); +``` + +--- + +### 3. 权限过滤与动态注册 + +**文件**: `src/store/modules/permission.js` + +```javascript +import router from '@/router' +import { publicRoutes, privateRoutes } from '@/router' + +/** + * 递归过滤路由(保留 component 引用) + * 规则: + * 1. 路由有 name 且在权限列表中 → 保留 + * 2. 路由无 name 但有子路由 → 检查子路由权限,有权限子路由则保留父路由 + */ +function filter(data, menus) { + const result = [] + + data.forEach(route => { + const newRoute = { ...route } // 浅拷贝,保留 component 引用 + + if (route.name && menus?.includes(route.name)) { + // 有权限:递归处理子路由 + if (route.children && route.children.length > 0) { + newRoute.children = filter(route.children, menus) + } + result.push(newRoute) + } else if (!route.name && route.children && route.children.length > 0) { + // 父路由无 name:检查子路由 + const filteredChildren = filter(route.children, menus) + if (filteredChildren.length > 0) { + newRoute.children = filteredChildren + result.push(newRoute) + } + } + }) + + return result +} + +actions: { + filterRoutes(context, menus) { + let routes = [] + + if (menus && menus.length > 0) { + routes = filter(privateRoutes, menus) + } + + // ★★★ 关键:动态添加路由到 Vue Router ★★★ + routes.forEach(route => { + router.addRoute(route) + }) + + // 404 兜底路由必须最后添加 + router.addRoute({ + path: '/:catchAll(.*)', + redirect: '/404' + }) + + context.commit('setRoutes', routes) + return routes + } +} +``` + +**重要说明**: +- 不能使用 `JSON.parse(JSON.stringify())` 深拷贝,会丢失 `component` 函数引用导致页面空白 +- 使用 `{ ...route }` 浅拷贝保留 `component` 引用 + +--- + +### 4. 侧边栏菜单渲染 + +**文件**: `src/layout/components/SideBar/SideBarMenu.vue` + +```javascript +// 从已注册的路由中获取并过滤 +const routes = computed(() => { + const fRoutes = filterRoutes(router.getRoutes()); + const data = fRoutes.filter((item) => !EXCLUDE_NAMES.includes(item.name)); + + const menusPermission = getItem("menusPermission"); + + if (menusPermission === null || menusPermission === undefined) { + return []; + } + + const menusSet = new Set(menusPermission.map((item) => `${item}`)); + + const permissionFiltered = menusSet.size + ? filterRoutesByMenusPermission(data, menusSet) + : []; + + return generateMenus(permissionFiltered); +}); +``` + +--- + +### 5. 404 无权限页面 + +**文件**: `src/views/error/404.vue` + +无权限访问时统一跳转此页面,显示提示信息和操作按钮: + +```vue + + + +``` + +--- + +### 6. 退出登录 + +**文件**: `src/store/modules/user.js` + +```javascript +async logout(ctx) { + const res = await loginOut(); + if (res) { + // 重置动态路由 + resetRouter(); + // 重置路由守卫初始化标记 + resetRoutesInit(); + // 清除权限模块状态 + ctx.dispatch("permission/resetRoutes"); + // 清除用户状态 + ctx.commit("user/setToken", ""); + ctx.commit("user/setUserName", "admin"); + ctx.commit("user/setUserInfo", {}); + // 清除本地存储 + removeAllItem(); + // 跳转统一门户 + window.location.href = `https://tyyy.lz.dsj.xz/portal/home`; + } +} +``` + +--- + +## 权限判断核心 + +### 匹配公式 + +``` +用户权限码:menusPermission = ["FourColorWarning", "YjData", "userList", ...] + +路由配置: +{ + path: "/FourColorWarning", + name: "FourColorWarning", // ← 必须与权限码一致 + meta: { title: "预警中心", icon: "article-ranking" }, + children: [...] +} + +判断逻辑: +menusPermission.includes(route.name) ? 有权限 : 无权限 +``` + +### 关键点 + +| 要素 | 说明 | +|-----|------| +| **权限来源** | 后端登录接口返回的 `menuCodeSet` | +| **存储位置** | `localStorage.menusPermission` | +| **匹配字段** | 路由的 `name` 属性 | +| **匹配方式** | 数组 `includes` 检查 | +| **父路由处理** | 无 `name` 时检查子路由,有权限子路由则保留父路由 | + +--- + +## 权限控制层级 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 权限控制层级 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ 第一层:路由守卫 │ +│ - 控制登录状态 │ +│ - 未登录跳转登录页 │ +│ - 白名单路由直接放行 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ 第二层:动态路由注册 │ +│ - 根据权限码筛选路由 │ +│ - 无权限路由不注册 → 用户输入 URL 直接 404 │ +│ - 只在登录时计算一次,性能最优 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ 第三层:侧边栏菜单过滤 │ +│ - 从已注册路由中筛选 │ +│ - 只显示有权限的菜单项 │ +│ - 支持特殊部门/角色的额外过滤 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ 第四层:按钮级权限 │ +│ - 使用 v-permission 指令控制按钮显示 │ +│ - 根据用户的功能权限动态移除无权限元素 │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 安全特性 + +### 动态路由方案的安全优势 + +| 特性 | 说明 | +|-----|------| +| **路由不存在** | 无权限路由不会注册到 Vue Router,从根本上杜绝越权访问 | +| **URL 直接访问** | 用户输入无权限 URL 直接跳转 404 | +| **性能最优** | 只在登录时计算一次,后续跳转无需校验 | +| **业界标准** | Vue 官方推荐的权限控制方案 | + +### 无权限访问流程 + +``` +用户访问无权限 URL(如 /user/userList) + │ + ▼ + 路由是否已注册? + │ + ┌─────┴─────┐ + │ 否 │ 是 + ▼ ▼ + 跳转 404 正常访问(但不会发生,因为无权限路由不会注册) +``` + +--- + +## 特殊权限处理 + +### 部门类型 + 角色组合 + +系统根据部门类型(`deptBizType`)和角色(`roleList`)进行特殊路由控制: + +| 条件 | 排除的路由 | +|-----|----------| +| `deptBizType == '23'` 且有 `JS_666666` 角色 | 不排除任何路由 | +| `deptBizType == '23'` 且有 `JS_999999` 角色 | 排除 `/internalAuditor` | +| 其他情况 | 排除 `/internalAuditor` 和 `/auditList` | + +--- + +## 常见问题 + +### Q1: 页面空白? + +检查项: +1. 是否使用了 `JSON.parse(JSON.stringify())` 深拷贝路由(会丢失 component) +2. 路由的 `name` 是否与权限码一致 +3. 控制台是否有报错 + +### Q2: 菜单不显示? + +检查项: +1. `localStorage.menusPermission` 是否存在 +2. 路由是否包含 `meta.title` 和 `meta.icon` +3. 路由是否在 `EXCLUDE_NAMES` 列表中 + +### Q3: 刷新后 404? + +检查项: +1. `localStorage.menusPermission` 是否存在 +2. 路由守卫是否正确重新初始化 + +### Q4: 退出登录后无法重新登录? + +检查项: +1. 是否正确调用了 `resetRoutesInit()` 重置初始化标记 +2. 是否正确清除了 localStorage + +--- + +## 相关文件索引 + +| 文件 | 说明 | +|-----|------| +| `src/router/index.js` | 路由配置(publicRoutes / privateRoutes) | +| `src/store/modules/permission.js` | 权限路由模块(过滤 + 动态注册) | +| `src/store/modules/user.js` | 用户模块(登录/退出) | +| `src/permission.js` | 路由守卫 | +| `src/utils/route.js` | 路由工具函数 | +| `src/layout/components/SideBar/SideBarMenu.vue` | 侧边栏菜单 | +| `src/layout/components/NavBar.vue` | 导航栏(退出登录) | +| `src/views/error/404.vue` | 无权限/404 页面 | +| `src/directives/permission.js` | 按钮级权限指令 | diff --git a/docs/麒麟系统文件下载兼容性修复方案.md b/docs/麒麟系统文件下载兼容性修复方案.md new file mode 100644 index 0000000..e78bad3 --- /dev/null +++ b/docs/麒麟系统文件下载兼容性修复方案.md @@ -0,0 +1,209 @@ +# 麒麟系统浏览器文件下载兼容性修复方案 + +## 一、问题现象 + +**下载成功,但文件打不开**。文件已保存到本地,但用对应软件打开时提示损坏或格式错误。 + +## 二、根因分析 + +### 可能原因 1:`URL.revokeObjectURL` 释放过早(文件内容损坏) + +当前代码(第300-313行): + +```javascript +function downloadFile(url, filename) { + fetch(url) + .then((response) => response.blob()) + .then((blob) => { + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); // 同步释放,可能过早 + }) +} +``` + +`link.click()` 在麒麟浏览器中是异步的,同步调用 `revokeObjectURL` 会在 Blob 数据写入磁盘前就销毁它,导致文件内容不完整。 + +--- + +### 可能原因 2:文件名无后缀 / 后缀被篡改 + +分析文件名传递链路: + +**上传时**,`handlerSuccess` 保存的是 `{ id, name }`,`name` 来自浏览器原始文件名(带后缀),这部分没问题。 + +**但回显时**,watch 中第183-188行存在一个关键问题: + +```javascript +// 当 modelValue 元素是字符串(非对象)时: +} else { + return { + url: String(`/mosty-api/mosty-base/minio/image/download/` + el || ""), + id: el + // ← 没有 name 属性! + }; +} +``` + +此时 `file.name` 为 `undefined`,传入 `downloadFile(file.url, file.name)`: + +```javascript +link.download = undefined; // 文件名丢失 +``` + +**麒麟浏览器的行为**:当 `download` 属性为空或 `undefined` 时,浏览器会: +- 从 URL 路径提取文件名(如 `/minio/image/download/abc123` → 文件名变成 `abc123`,**无后缀**) +- 或根据 Blob 的 `type` 自动添加后缀(如服务端返回的 Content-Type 是 `application/json` → 强制加 `.json` 后缀) + +**结果**:一个 `.docx` 文件下载后变成了 `.json` 或无后缀文件,自然打不开。 + +**验证方法**:在麒麟系统上下载一个文件,查看下载后的文件名是否和原始文件名一致(包括后缀)。 + +--- + +### 可能原因 3:麒麟系统安全机制拦截 + +麒麟系统(基于 Linux)自带安全中心,可能触发以下行为: + +| 安全机制 | 行为 | 结果 | +|----------|------|------| +| 文件隔离 | 将下载的文件标记为不可信,移到隔离区 | 文件存在但被锁,其他程序无法读取 | +| 执行权限 | 给文件添加可执行标记,或删除可执行标记 | 程序拒绝打开带危险标记的文件 | +| 杀毒扫描 | 实时扫描下载文件,误报则隔离 | 文件被移动或内容被修改 | +| WINE 兼容层 | 试图用 WINE 打开 Windows 格式文件 | 文件关联错误,打开方式不对 | + +**验证方法**: +1. 在麒麟系统终端执行 `ls -la` 查看下载文件是否有特殊权限标记 +2. 检查 `/tmp` 或隔离区目录是否有被拦截的文件 +3. 暂时关闭麒麟安全中心,重新下载测试 + +--- + +## 三、修复方案 + +### 方案 A:综合修复(推荐) + +同时解决原因1和原因2,并在下载失败时给出明确提示: + +```javascript +import { saveAs } from 'file-saver' + +// 补全文件名后缀 +function ensureFilename(file) { + if (file.name) return file.name + // name 丢失时,从 URL 中尝试提取,或使用默认名 + const urlId = file.url?.split('/').pop() + return urlId ? `文件_${urlId}` : '未命名文件' +} + +function downloadFile(url, filename) { + fetch(url) + .then((response) => { + if (!response.ok) { + throw new Error(`下载失败: ${response.status}`) + } + return response.blob() + }) + .then((blob) => { + // saveAs 内部处理了 Blob 释放时序,不会过早 revoke + saveAs(blob, filename) + }) + .catch((error) => { + console.error('下载失败:', error) + ElMessage.error('文件下载失败,请重试') + }) +} + +const handleDownload = (file) => { + if (file?.response?.data) { + window.open(file.response.data) + } else if (file?.url) { + const filename = ensureFilename(file) + downloadFile(file.url, filename) + } +} +``` + +**改动内容**: +1. 引入 `file-saver`(项目已有依赖)→ 解决 Blob 释放过早 +2. `ensureFilename` 补全文件名 → 解决后缀丢失 +3. 响应状态校验 → 发现服务端错误时提示用户 + +--- + +### 方案 B:仅修复 Blob 释放时序(最小改动) + +```javascript +function downloadFile(url, filename) { + fetch(url) + .then((response) => response.blob()) + .then((blob) => { + const blobUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + // 延迟释放,确保浏览器完成 Blob 数据拷贝 + setTimeout(() => URL.revokeObjectURL(blobUrl), 3000); + }) + .catch((error) => console.error("下载失败:", error)); +} +``` + +**改动量**:1 行。但未修复文件名丢失问题。 + +--- + +### 方案 C:window.open 直接下载 + +```javascript +const handleDownload = (file) => { + if (file?.url) { + window.open(file.url, "_blank"); + } +}; +``` + +**前提**:后端接口需设置 `Content-Disposition: attachment; filename="xxx.docx"` 响应头,浏览器才能正确处理文件名和下载行为。 + +--- + +## 四、排查步骤 + +建议按以下顺序验证,定位到底是哪个原因: + +1. **检查文件名**:在麒麟系统下载后,文件名是否带正确后缀(如 `.docx`、`.pdf`)? + - 后缀丢失/被改 → **原因2(文件名问题)** + - 后缀正确 → 排除原因2 + +2. **检查文件大小**:下载的文件大小是否和服务端一致? + - 文件明显偏小或0字节 → **原因1(Blob 释放过早)** + - 大小一致 → 排除原因1 + +3. **关闭安全中心测试**:暂时关闭麒麟安全中心,重新下载 + - 能正常打开 → **原因3(安全拦截)** + - 仍打不开 → 排除原因3 + +--- + +## 五、方案对比 + +| | 方案 A 综合修复 | 方案 B 延迟释放 | 方案 C window.open | +|---|---|---|---| +| 修复原因1(Blob释放) | ✅ | ✅ | 不涉及 | +| 修复原因2(文件名后缀) | ✅ | ❌ | 取决于后端 | +| 处理原因3(安全拦截) | ❌ 需系统配置 | ❌ | ❌ | +| 改动量 | ~15行 | 1行 | 最小 | + +**建议**:先执行排查步骤确认根因,再选择对应方案。如果原因1和2同时存在,直接用方案A。 + +--- + +**文档版本**: v3.0 +**创建日期**: 2026-04-24 diff --git a/gsxt.zip b/gsxt.zip index 09f03cf..913099d 100644 Binary files a/gsxt.zip and b/gsxt.zip differ diff --git a/package.json b/package.json index e982ae2..e4667a7 100644 --- a/package.json +++ b/package.json @@ -84,13 +84,4 @@ "svg-sprite-loader": "^6.0.9", "vue-cli-plugin-element-plus": "0.0.13" }, - "gitHooks": { - "pre-commit": "lint-staged" - }, - "lint-staged": { - "src/**/*.{js,vue}": [ - "eslint --fix", - "git add" - ] } -} diff --git a/src/App.vue b/src/App.vue index 4b8b1f0..1ae29d9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -45,7 +45,6 @@ onMounted(() => { - /** *@Descripttion:图片页面初始化 *@Author: PengShuai diff --git a/src/components/ChooseList/ChooseIdentity/index.vue b/src/components/ChooseList/ChooseIdentity/index.vue index 258fec6..b06855f 100644 --- a/src/components/ChooseList/ChooseIdentity/index.vue +++ b/src/components/ChooseList/ChooseIdentity/index.vue @@ -88,7 +88,11 @@ import { qcckGet } from "@/api/qcckApi.js"; import { defineProps, ref, getCurrentInstance, watch } from "vue"; const { proxy } = getCurrentInstance(); -const { D_GS_BQ_DJ, D_GS_SSYJ,D_GS_BQ_LB,D_GS_BQ_LX } = proxy.$dict("D_GS_BQ_DJ", "D_GS_SSYJ","D_GS_BQ_LB","D_GS_BQ_LX"); //获取字典数据 +const { D_GS_BQ_DJ, D_GS_SSYJ, + // D_GS_BQ_LB, D_GS_BQ_LX +} = proxy.$dict("D_GS_BQ_DJ", "D_GS_SSYJ" + // ,"D_GS_BQ_LB","D_GS_BQ_LX" +); //获取字典数据 const props = defineProps({ modelValue: { type: Boolean, diff --git a/src/components/MyComponents/Upload/index.vue b/src/components/MyComponents/Upload/index.vue index d51a0c3..41f2b08 100644 --- a/src/components/MyComponents/Upload/index.vue +++ b/src/components/MyComponents/Upload/index.vue @@ -4,14 +4,47 @@ :class="props.showBtn ? 'showBtn-upload' : ''" :style="{ width: width }" > + +
+ + 上传文件 + + +
+
+ + + {{ file.name }} + + + + + +
+
+
+ - - - - + + + @@ -38,7 +38,7 @@ diff --git a/src/views/backOfficeSystem/JudgmentHome/strategicResearch/addReport.vue b/src/views/backOfficeSystem/JudgmentHome/strategicResearch/addReport.vue index 485035b..bdb20d4 100644 --- a/src/views/backOfficeSystem/JudgmentHome/strategicResearch/addReport.vue +++ b/src/views/backOfficeSystem/JudgmentHome/strategicResearch/addReport.vue @@ -3,30 +3,21 @@
报告{{ title }}
- 保存 + 保存 关闭
- +
- - +
@@ -120,7 +111,7 @@ const title = ref(""); // 初始化数据 const init = (type, row) => { dialogForm.value = true; - title.value = type == "add" ? "新增" :type == "edit"? "编辑" : "详情"; + title.value = type == "add" ? "新增" : type == "edit" ? "编辑" : "详情"; if (row) { @@ -132,8 +123,9 @@ const init = (type, row) => { }; // 根据id查询详情 const getDataById = (id) => { - qcckGet({},'/mosty-gsxt/gsxtYpbg/'+id).then((res) => { + qcckGet({}, '/mosty-gsxt/gsxtYpbg/' + id).then((res) => { listQuery.value = res || {}; + textContent.value = res.bgnr // /** @type {Array} 参与研判部门数据数组 */ // const cyypList = Array.isArray(res.cyypList) ? res.cyypList : [] // listQuery.value.jsdxBmDm = cyypList.map(item => { @@ -150,18 +142,18 @@ const getText = (val) => { setEditorTextContent() } -function stripReportHeader(html) { - const source = typeof html === "string" ? html : ""; - if (!source) return ""; - const hrMatch = source.match(/]*\/?>/i); - if (hrMatch && typeof hrMatch.index === "number") { - return source.slice(hrMatch.index + hrMatch[0].length).trim(); - } - if (typeof dataBt.value === "string" && source.startsWith(dataBt.value)) { - return source.slice(dataBt.value.length).trim(); - } - return source.trim(); -} +// function stripReportHeader(html) { +// const source = typeof html === "string" ? html : ""; +// if (!source) return ""; +// const hrMatch = source.match(/]*\/?>/i); +// if (hrMatch && typeof hrMatch.index === "number") { +// return source.slice(hrMatch.index + hrMatch[0].length).trim(); +// } +// if (typeof dataBt.value === "string" && source.startsWith(dataBt.value)) { +// return source.slice(dataBt.value.length).trim(); +// } +// return source.trim(); +// } function setEditorTextContent() { let html = dataBt.value; @@ -172,11 +164,11 @@ function setEditorTextContent() { // 提交 const submit = () => { - elform.value.submit( async (data) => { + elform.value.submit(async (data) => { loading.value = true; const params = { ...data, - bgnr: stripReportHeader(textContent.value) + bgnr: textContent.value }; const apiFun = !listQuery.value.id ? gsxtYpbgAddEntity : gsxtYpbgEditEntity; if (!listQuery.value.id) delete params.id; @@ -211,7 +203,7 @@ const close = () => { loading.value = false; dialogForm.value = false; listQuery.value = {} - router.replace({ path: '/strategicResearchs' })// 移除id 避免刷新一直带参数 + router.replace({ path: '/strategicResearchs' })// 移除id 避免刷新一直带参数 }; diff --git a/src/views/backOfficeSystem/JudgmentHome/tsypHome/components/list.vue b/src/views/backOfficeSystem/JudgmentHome/tsypHome/components/list.vue index 8eb055f..c07d14a 100644 --- a/src/views/backOfficeSystem/JudgmentHome/tsypHome/components/list.vue +++ b/src/views/backOfficeSystem/JudgmentHome/tsypHome/components/list.vue @@ -10,7 +10,7 @@
@@ -34,7 +34,7 @@ const props = defineProps({ }) const { proxy } = getCurrentInstance(); -const { D_BB_AJLB,D_BZ_WPLX,D_BZ_RYBQ} = proxy.$dict("D_BB_AJLB","D_BZ_WPLX","D_BZ_RYBQ") +const { /* D_BB_AJLB, */ D_BZ_WPLX, D_BZ_RYBQ } = proxy.$dict(/* "D_BB_AJLB", */ "D_BZ_WPLX", "D_BZ_RYBQ") const title = ref("新增") const emit = defineEmits(['getList']) const listQuery = ref() diff --git a/src/views/backOfficeSystem/JudgmentHome/tsypHome/components/yjList.vue b/src/views/backOfficeSystem/JudgmentHome/tsypHome/components/yjList.vue index 5f5655a..d5e712f 100644 --- a/src/views/backOfficeSystem/JudgmentHome/tsypHome/components/yjList.vue +++ b/src/views/backOfficeSystem/JudgmentHome/tsypHome/components/yjList.vue @@ -46,7 +46,7 @@ const props = defineProps({ }) const { proxy } = getCurrentInstance(); -const { D_BB_AJLB,D_BZ_WPLX} = proxy.$dict("D_BB_AJLB","D_BZ_WPLX") +const { /* D_BB_AJLB, */ D_BZ_WPLX } = proxy.$dict(/* "D_BB_AJLB", */ "D_BZ_WPLX") const regulation = ref(null) const queryFrom = ref({}) const searchBox = ref(); //搜索框 diff --git a/src/views/backOfficeSystem/JudgmentHome/tsypHome/index.vue b/src/views/backOfficeSystem/JudgmentHome/tsypHome/index.vue index e923c48..892a0f7 100644 --- a/src/views/backOfficeSystem/JudgmentHome/tsypHome/index.vue +++ b/src/views/backOfficeSystem/JudgmentHome/tsypHome/index.vue @@ -14,7 +14,7 @@ import WarningList from "./components/AddModel/warningList.vue" const { proxy } = getCurrentInstance(); import emitter from "@/utils/eventBus.js"; import { onMounted, ref, getCurrentInstance } from "vue"; -const { D_BZ_TPYJLX,D_BZ_YJLX ,D_BZ_JQLY} = proxy.$dict("D_BZ_TPYJLX","D_BZ_YJLX","D_BZ_JQLY") +const { /* D_BZ_TPYJLX, */D_BZ_YJLX /* ,D_BZ_JQLY */} = proxy.$dict(/* "D_BZ_TPYJLX", */"D_BZ_YJLX"/* ,"D_BZ_JQLY" */) const showModel = ref('研判首页') const itemData = ref({}) diff --git a/src/views/backOfficeSystem/PoliceIncidentMonitoring/gljqLod.vue b/src/views/backOfficeSystem/PoliceIncidentMonitoring/gljqLod.vue index b2ae44e..0093d7c 100644 --- a/src/views/backOfficeSystem/PoliceIncidentMonitoring/gljqLod.vue +++ b/src/views/backOfficeSystem/PoliceIncidentMonitoring/gljqLod.vue @@ -1,9 +1,9 @@ + + + + + + + +
@@ -57,8 +74,9 @@ import Xslist from '@/components/ChooseList/ChooseXs/index.vue' import FormMessage from '@/components/aboutTable/FormMessage.vue' import * as MOSTY from '@/components/MyComponents/index' import { qcckGet, qcckPost, qcckPut } from "@/api/qcckApi.js"; +import { saveAs } from 'file-saver'; import { useRouter } from 'vue-router' -import { ref, defineExpose, reactive, onMounted, defineEmits, getCurrentInstance, nextTick ,watch} from "vue"; +import { ref, defineExpose, reactive, onMounted, defineEmits, getCurrentInstance, nextTick, watch } from "vue"; const emit = defineEmits(["updateDate"]); const props = defineProps({ dic: { @@ -83,13 +101,13 @@ watch(() => props.dic, (newVal) => { { label: "联系人", prop: "zllxr", type: "input" }, { label: "联系电话", prop: "zllxdh", type: "input" }, { label: "关联线索", prop: "glxsid", type: "slot" }, - { label: "主送单位", prop: "zsdw", type: "department" }, + { label: "主送单位", prop: "zsdw", type: "department", depMc: "zsdwMc" }, { label: "抄送单位", prop: "csdw", type: "department" }, { label: "指令内容", prop: "zlnr", type: "textarea", width: '100%' }, { label: "附件", prop: "fjzd", type: "slot", width: '100%' }, ] } -},{deep: true}) +}, { deep: true }) const listQuery = ref({}); //表单 const loading = ref(false); @@ -118,9 +136,17 @@ const init = (type, row,) => { // 根据id查询详情 const getDataById = (id) => { qcckGet({ id }, '/mosty-gsxt/zlxx/selectByid').then((res) => { - res.fjzd = res.fjzd ? res.fjzd.split(',') : []; + // 解析附件:兼容JSON字符串和逗号分隔两种格式 + let fjzdList = [] + try { + const parsed = res.fjzd ? JSON.parse(res.fjzd) : [] + fjzdList = Array.isArray(parsed) ? parsed : [] + } catch (e) { + fjzdList = res.fjzd ? res.fjzd.split(',') : [] + } listQuery.value = { ...res, + fjzd: fjzdList, czlcList: res.czlcList ? res.czlcList.reverse() : [] }; }); @@ -139,7 +165,18 @@ const submit = () => { let url = title.value == "新增" ? '/mosty-gsxt/zlxx/add' : '/mosty-gsxt/zlxx/update'; let params = { ...data } loading.value = true; - params.fjzd = params.fjzd ? params.fjzd.join(',') : '' + // 将附件转为JSON字符串(包含id和name) + let fjzdList = [] + if (Array.isArray(params.fjzd)) { + params.fjzd.forEach(item => { + if (Object.prototype.toString.call(item) === '[object Object]') { + fjzdList.push({ id: item.id, name: item.name }) + } else { + fjzdList.push({ id: item }) + } + }) + } + params.fjzd = fjzdList.length > 0 ? JSON.stringify(fjzdList) : '' // 将主送单位和抄送单位转换为下发部门列表 let xfbmList = []; const zsdwArr = Array.isArray(params.zsdw) ? params.zsdw : (params.zsdw ? [params.zsdw] : []); @@ -167,6 +204,34 @@ const close = () => { loading.value = false; router.replace({ path: '/InstructionInformation' })// 移除id 避免刷新一直带参数 }; + +// 解析附件JSON字符串为数组 +const parseFkfj = (fkfj) => { + if (!fkfj) return [] + try { + const parsed = typeof fkfj === 'string' ? JSON.parse(fkfj) : fkfj + return Array.isArray(parsed) ? parsed : [] + } catch (e) { + return [] + } +} + +// 下载文件 +const downloadFile = (file) => { + const url = `/mosty-api/mosty-base/minio/image/download/${file.id}` + const filename = file.name || file.id + fetch(url) + .then(response => { + if (!response.ok) throw new Error('下载失败') + return response.blob() + }) + .then(blob => { + saveAs(blob, filename) + }) + .catch(() => { + proxy.$message({ type: 'error', message: '下载失败' }) + }) +} defineExpose({ init }); diff --git a/src/views/backOfficeSystem/ResearchJudgment/InformationFlows/InstructionInformation/components/fk.vue b/src/views/backOfficeSystem/ResearchJudgment/InformationFlows/InstructionInformation/components/fk.vue index d3bdf9b..c63598f 100644 --- a/src/views/backOfficeSystem/ResearchJudgment/InformationFlows/InstructionInformation/components/fk.vue +++ b/src/views/backOfficeSystem/ResearchJudgment/InformationFlows/InstructionInformation/components/fk.vue @@ -1,7 +1,14 @@