Skip to content

Commit 1e0cb7f

Browse files
committed
feat: permission
1 parent b74c427 commit 1e0cb7f

File tree

8 files changed

+592
-324
lines changed

8 files changed

+592
-324
lines changed

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,54 @@ http://localhost:8080/auth/callback
399399

400400
## Sentry 接入
401401

402+
## **更新摘要(近期权限与请求模块改进)**
403+
404+
以下为最近在仓库中完成的主要改动与改进,便于团队成员快速了解变更并在本地验证:
405+
406+
- **权限与导航**
407+
- 统一并强化了权限检查逻辑,`permissionService` 提供缓存、过期策略、强制刷新与单例获取,避免重复请求和竞态。
408+
- 在侧边栏 `src/pages/layout/proSecNav/index.jsx` 引入了基于权限的菜单过滤,确保首页 `/` 对所有账号可见,并避免在无权限时强制跳转或刷新。
409+
- 引入“安全导航”封装(`useSafeNavigate` / `SafeLink` 等),在点击或程序化导航前检查权限,阻止越权访问并提供友好提示。
410+
411+
- **请求层重构**
412+
- 全新增强 `src/service/request.js`:基于 `axios`,支持全局配置、可取消请求(AbortController)、上传/下载进度回调、并发/串行执行控制、重试机制以及每次请求可关闭错误弹窗(`config.showError = false`)。
413+
- 新的请求模块向后兼容常见用法,同时提供 `request.parallel` / `request.series` / `request.retry` 等工具函数,便于批量请求与限流。
414+
- 为请求错误提示增加了统一处理,并与消息中心集成(消息去重,见下)。
415+
416+
- **消息去重**
417+
- 修改了 `src/utils/message.ts`:对短时间(2s 窗口)内相同文本的消息进行去重,避免网络抖动或多处错误导致的重复弹窗。
418+
419+
- **文档与示例**
420+
- 新增 `docs/REQUESTS.md`,包含对新版 `request` 模块的使用示例:并发、串行、取消、上传/下载进度、重试、关闭错误提示等场景,用法清晰易懂,建议开发者参考并在新代码中使用。
421+
422+
- **自动化测试与迁移提示**
423+
- 已配合 Playwright 增加部分 E2E 用例(授权/未授权场景),建议在变更请求或权限逻辑后运行 `npm run test:e2e` 以回归验证。
424+
425+
如何验证本地改动(简要)
426+
427+
- 先安装依赖并构建:
428+
429+
```bash
430+
npm install
431+
npm run build:production
432+
```
433+
434+
- 本地调试:启动 dev 或 mock server:
435+
436+
```bash
437+
npm start
438+
# 或使用 faker mock
439+
npm run dev:faker
440+
```
441+
442+
- 运行 E2E(如已配置 Playwright):
443+
444+
```bash
445+
npm run test:e2e
446+
```
447+
448+
如需我把项目中仍存在的直接重复请求点(例如多个组件在 mount 时各自向后端请求权限)替换为 `permissionService.getPermissions()` 的单点调用,我可以扫描并提供自动补丁或直接提交变更,请告知偏好工作方式。
449+
402450
1. [Sentry](https://sentry.io/)
403451
2. 遇到的问题:
404452
- ERROR in Sentry CLI Plugin: spawn /Users/sheldon/Desktop/promotion-manage-web/node_modules/@sentry/cli/sentry-cli ENOENT

docs/REQUESTS.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
**请求模块 使用说明**
2+
3+
位置: `src/service/request.js`
4+
5+
简介
6+
- 本文档说明如何使用仓库中增强过的 `request` 模块。该模块基于 `axios`,提供:可取消请求、并发/串行执行、重试机制、上传/下载进度回调、每次请求可开关错误弹窗及全局配置接口。
7+
8+
导入
9+
10+
```js
11+
import request from '@/service/request'
12+
```
13+
14+
1) 全局配置
15+
16+
- 设置 baseURL:
17+
18+
```js
19+
request.setBaseURL('https://api.example.com')
20+
```
21+
22+
- 设置全局默认 headers:
23+
24+
```js
25+
request.setDefaultHeaders({ 'X-Trace-Id': 'abc123' })
26+
```
27+
28+
2) 基本请求(返回 Promise,同时附带 `.cancel()`
29+
30+
```js
31+
// GET
32+
const p = request.get('/api/users', { page: 1 })
33+
p.then(res => console.log(res)).catch(err => console.error(err))
34+
35+
// 取消请求
36+
p.cancel()
37+
38+
// POST
39+
request.post('/api/user', { name: 'alice' })
40+
.then(res => console.log(res))
41+
.catch(err => console.error(err))
42+
43+
// 禁用该次请求的错误弹窗
44+
request.get('/api/maybe-missing', {}, { showError: false })
45+
```
46+
47+
注意:当后端采用 `{ code, data, message }` 约定时,模块会在 `code === 0` 视作成功并返回后端对象;否则会按 `message` 展示错误(可用 `showError:false` 关闭)。如果后端返回不是该结构,则直接返回原始数据。
48+
49+
3) 上传与下载(支持进度回调)
50+
51+
```js
52+
// 上传
53+
const fd = new FormData()
54+
fd.append('file', fileInput.files[0])
55+
request.upload('/api/upload', fd, {
56+
onUploadProgress: (e) => {
57+
const pct = Math.round((e.loaded / e.total) * 100)
58+
console.log('上传进度', pct)
59+
},
60+
}).then(res => console.log('上传完成', res))
61+
62+
// 下载(会触发浏览器下载)
63+
request.download('/api/export', { q: 'all' }, 'report.xlsx', {
64+
onDownloadProgress: (e) => {
65+
const pct = Math.round((e.loaded / e.total) * 100)
66+
console.log('下载进度', pct)
67+
}
68+
}).then(() => console.log('下载结束'))
69+
```
70+
71+
4) 并行 / 并发控制
72+
73+
```js
74+
// 简单并行:传入请求描述数组,返回 Promise 数组结果
75+
// 描述可以是:函数、axios config 对象,或 [method, url, body, config]
76+
const results = await request.parallel([
77+
['get', '/api/a'],
78+
['post', '/api/b', { x: 1 }],
79+
() => request.get('/api/c')
80+
], /* concurrency */ 3)
81+
82+
console.log(results)
83+
```
84+
85+
5) 串行
86+
87+
```js
88+
// 依次执行
89+
const seriesResults = await request.series([
90+
() => request.get('/api/step1'),
91+
['post', '/api/step2', { id: 123 }],
92+
])
93+
```
94+
95+
6) 可取消的自定义请求
96+
97+
```js
98+
const promise = request.request({ method: 'get', url: '/api/long', params: { t: 1 } })
99+
// 取消
100+
promise.cancel()
101+
```
102+
103+
7) 重试(对任意返回 Promise 的函数)
104+
105+
```js
106+
await request.retry(() => request.get('/api/maybe-flaky'), 3, 1000)
107+
```
108+
109+
8) 访问底层 axios(高级用法)
110+
111+
```js
112+
const ax = request.axios()
113+
ax.get('/raw/endpoint').then(r => console.log(r))
114+
```
115+
116+
9) 常见使用场景示例
117+
- 场景 A:页面 mount 时保护性加载权限数据,避免重复请求
118+
119+
```js
120+
// 使用 permissionService(单例)来避免多个组件重复向后端请求权限
121+
import permissionService from '@/service/permissionService'
122+
// 在 App 初始化处调用一次
123+
permissionService.getPermissions()
124+
```
125+
126+
- 场景 B:文件导出,提供进度与取消
127+
128+
```js
129+
const downloadPromise = request.download('/api/export', {}, 'report.xlsx', {
130+
onDownloadProgress: (e) => console.log('progress', e.loaded)
131+
})
132+
// 在需要时取消
133+
// downloadPromise.cancel()
134+
```
135+
136+
- 场景 C:批量请求但限制并发(防止后端压力)
137+
138+
```js
139+
const tasks = urls.map((u) => ['get', u])
140+
const results = await request.parallel(tasks, 4)
141+
```
142+
143+
备注
144+
- 默认超时时间与 baseURL 可通过 `request.setBaseURL``request.setDefaultHeaders` 调整。
145+
- 单次请求关闭弹窗:传 `config.showError = false`
146+
- 所有请求返回的 Promise 都包含 `.cancel()`(内部使用 AbortController)。
147+
148+
如需我把示例中的某些场景自动替换到代码中(例如把多个直接调用 `permissionAPI.getUserPermissions()` 的位置替换为 `permissionService.getPermissions()`),我可以继续扫描并提交补丁。

src/mock/permission.ts

Lines changed: 110 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -216,35 +216,116 @@ export const mockUserPermissions: Record<string, UserPermission> = {
216216
permissions: ['*:*'],
217217
routes: adminRoutes,
218218
},
219-
admin: {
220-
userId: '2',
221-
username: '管理员',
222-
roles: [mockRoles[1]],
223-
permissions: [
224-
'home:read',
225-
'user:read',
226-
'user:create',
227-
'user:update',
228-
'dashboard:read',
229-
'business:read',
230-
'coupons:read',
231-
],
232-
routes: managerRoutes,
233-
},
234-
business_user: {
235-
userId: '3',
236-
username: '业务员',
237-
roles: [mockRoles[2]],
238-
permissions: ['home:read', 'business:*', 'coupons:read', 'coupons:create', 'dashboard:read'],
239-
routes: businessRoutes,
240-
},
241-
user: {
242-
userId: '4',
243-
username: '普通用户',
244-
roles: [mockRoles[3]],
245-
permissions: ['home:read', 'dashboard:read'],
246-
routes: userRoutes,
247-
},
219+
admin: ((): UserPermission => {
220+
// 为管理员根据 routes 自动收集 permissions
221+
const base = ['home:read', 'user:read', 'user:create', 'user:update', 'dashboard:read']
222+
const perms = new Set<string>(base)
223+
const collect = (routes: string[]) => {
224+
routes.forEach((r) => {
225+
let p = routePermissionMap[r]
226+
if (!p) {
227+
// 尝试向上匹配父路径或带参数的模式
228+
let temp = r
229+
while (temp && temp !== '/') {
230+
const candidateParam = `${temp}/:id`
231+
if (routePermissionMap[candidateParam]) {
232+
p = routePermissionMap[candidateParam]
233+
break
234+
}
235+
const idx = temp.lastIndexOf('/')
236+
if (idx <= 0) break
237+
temp = temp.substring(0, idx)
238+
if (routePermissionMap[temp]) {
239+
p = routePermissionMap[temp]
240+
break
241+
}
242+
}
243+
}
244+
if (!p && routePermissionMap['*']) p = routePermissionMap['*']
245+
if (p) perms.add(p)
246+
})
247+
}
248+
collect(managerRoutes)
249+
return {
250+
userId: '2',
251+
username: '管理员',
252+
roles: [mockRoles[1]],
253+
permissions: Array.from(perms) as PermissionCode[],
254+
routes: managerRoutes,
255+
}
256+
})(),
257+
business_user: ((): UserPermission => {
258+
const base = ['home:read', 'business:*', 'coupons:read', 'coupons:create', 'dashboard:read']
259+
const perms = new Set<string>(base)
260+
const collect = (routes: string[]) => {
261+
routes.forEach((r) => {
262+
let p = routePermissionMap[r]
263+
if (!p) {
264+
let temp = r
265+
while (temp && temp !== '/') {
266+
const candidateParam = `${temp}/:id`
267+
if (routePermissionMap[candidateParam]) {
268+
p = routePermissionMap[candidateParam]
269+
break
270+
}
271+
const idx = temp.lastIndexOf('/')
272+
if (idx <= 0) break
273+
temp = temp.substring(0, idx)
274+
if (routePermissionMap[temp]) {
275+
p = routePermissionMap[temp]
276+
break
277+
}
278+
}
279+
}
280+
if (!p && routePermissionMap['*']) p = routePermissionMap['*']
281+
if (p) perms.add(p)
282+
})
283+
}
284+
collect(businessRoutes)
285+
return {
286+
userId: '3',
287+
username: '业务员',
288+
roles: [mockRoles[2]],
289+
permissions: Array.from(perms) as PermissionCode[],
290+
routes: businessRoutes,
291+
}
292+
})(),
293+
user: ((): UserPermission => {
294+
const base = ['home:read', 'dashboard:read']
295+
const perms = new Set<string>(base)
296+
const collect = (routes: string[]) => {
297+
routes.forEach((r) => {
298+
let p = routePermissionMap[r]
299+
if (!p) {
300+
let temp = r
301+
while (temp && temp !== '/') {
302+
const candidateParam = `${temp}/:id`
303+
if (routePermissionMap[candidateParam]) {
304+
p = routePermissionMap[candidateParam]
305+
break
306+
}
307+
const idx = temp.lastIndexOf('/')
308+
if (idx <= 0) break
309+
temp = temp.substring(0, idx)
310+
if (routePermissionMap[temp]) {
311+
p = routePermissionMap[temp]
312+
break
313+
}
314+
}
315+
}
316+
if (!p && routePermissionMap['*']) p = routePermissionMap['*']
317+
if (p) perms.add(p)
318+
})
319+
}
320+
collect(userRoutes)
321+
return {
322+
userId: '4',
323+
username: '普通用户',
324+
roles: [mockRoles[3]],
325+
permissions: Array.from(perms) as PermissionCode[],
326+
routes: userRoutes,
327+
}
328+
})(),
248329
}
249330

250331
/**

src/pages/layout/proSecNav/index.module.less

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
.menu :global(.ant-menu-inline .ant-menu-submenu .ant-menu-submenu .ant-menu-submenu .ant-menu-submenu-title) {
2121
padding-left: 36px !important;
2222
}
23-
.menu :global(.ant-menu-inline .ant-menu-submenu .ant-menu-submenu .ant-menu-submenu .ant-menu-submenu .ant-menu-submenu-title) {
23+
.menu
24+
:global(
25+
.ant-menu-inline .ant-menu-submenu .ant-menu-submenu .ant-menu-submenu .ant-menu-submenu .ant-menu-submenu-title
26+
) {
2427
padding-left: 44px !important;
2528
}

0 commit comments

Comments
 (0)