lcw
This commit is contained in:
129
docs/navigation-issue-solution.md
Normal file
129
docs/navigation-issue-solution.md
Normal file
@ -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` | 添加空值安全检查 |
|
||||
463
docs/菜单权限逻辑文档.md
Normal file
463
docs/菜单权限逻辑文档.md
Normal file
@ -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
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<div class="error-content">
|
||||
<h1>404</h1>
|
||||
<h2>无权限访问</h2>
|
||||
<p>您没有权限访问此页面,请联系上级部门添加相关权限。</p>
|
||||
<el-button type="primary" @click="goHome">返回首页</el-button>
|
||||
<el-button @click="logout">退出登录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const logout = () => {
|
||||
window.opener = null;
|
||||
window.open('', '_self');
|
||||
window.close();
|
||||
store.commit("app/clearTag", null, { immediate: true });
|
||||
store.commit("permission/deleteRouter", { immediate: true });
|
||||
store.commit("user/deleteKeepLiiveRoute", "home");
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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` | 按钮级权限指令 |
|
||||
209
docs/麒麟系统文件下载兼容性修复方案.md
Normal file
209
docs/麒麟系统文件下载兼容性修复方案.md
Normal file
@ -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
|
||||
Reference in New Issue
Block a user