feat: 兼容前端按钮权限数据
This commit is contained in:
44
.superpowers/sdd/task-3-report.md
Normal file
44
.superpowers/sdd/task-3-report.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Task 3:前端权限数据与菜单路由兼容
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
|
||||||
|
- AppStore 新增 `permissionCodes: string[]`,在权限响应过滤前保存全部非空权限码并去重。
|
||||||
|
- 动态菜单在进入 `buildTree` 前严格筛选 `type === 1`;路由转换入口与子路由递归同时防御性过滤非菜单节点。
|
||||||
|
- `clearServerMenu()` 同步清空 `serverMenu` 与 `permissionCodes`,现有退出登录链路无需改动即可完成权限清理。
|
||||||
|
- 新增 `usePermissionCodes` Hook,统一提供响应式权限码集合与 `hasPermission(code)`,不解析角色名称。
|
||||||
|
- 删除权限响应、树构建、路由转换及视图模块映射的相邻调试输出和无用导入。
|
||||||
|
|
||||||
|
## 变更文件
|
||||||
|
|
||||||
|
- `src/store/modules/app/index.ts`
|
||||||
|
- `src/store/modules/app/types.ts`
|
||||||
|
- `src/router/menu-data.ts`
|
||||||
|
- `src/hooks/usePermissionCodes.ts`
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
1. `pnpm exec prettier src/store/modules/app/index.ts src/store/modules/app/types.ts src/router/menu-data.ts src/hooks/usePermissionCodes.ts --write`
|
||||||
|
- 结果:成功,随后冻结源码。
|
||||||
|
2. 首轮 `pnpm exec vue-tsc --noEmit --incremental false`
|
||||||
|
- 结果:全仓 828 条错误,改动文件 1 条;定位为 Hook 中 Pinia 索引签名导致 `permissionCodes.value` 被推断为 `unknown`。
|
||||||
|
- 处理:仅在 Hook 响应式边界补充显式 `string[]` 类型,重新格式化并再次冻结源码。
|
||||||
|
3. 最终 `pnpm exec vue-tsc --noEmit --incremental false`
|
||||||
|
- 结果:退出码 2,全仓错误行 827,与已确认基线一致;四个改动文件错误行 0,本次新增类型错误为 0。
|
||||||
|
4. `pnpm build -- --outDir C:\Users\27105\AppData\Local\Temp\front-kb-task3-build-20260721`
|
||||||
|
- 结果:退出码 0,Vite 7.3.1 成功转换 9055 个模块并完成生产构建。
|
||||||
|
- 清理:临时构建目录已删除;误启动的默认 `dist` 产物也已删除,仓库内无构建产物残留。
|
||||||
|
5. 内联 Node + 最小 Vite SSR 加载器模拟 `type=1` 菜单和 `type=2` 按钮权限响应,直接调用实际 `buildTree` 与 `transformMenuToRoutes`。
|
||||||
|
- 结果:`permission_codes=kb:article:list,kb:article:create`、`menu_permissions=1`、`routes=1`、`empty_path=false`、`redirect=false`、`button_in_routes=false`,退出码 0。
|
||||||
|
6. `git diff --check`
|
||||||
|
- 结果:退出码 0。
|
||||||
|
|
||||||
|
## 自审
|
||||||
|
|
||||||
|
- 完整权限码在菜单过滤前保存,按钮权限不会因菜单过滤丢失。
|
||||||
|
- `type=2` 不进入树构建,也无法从递归路由转换入口生成空路径或重定向路由。
|
||||||
|
- 退出登录继续沿用现有 `userStore.logoutCallBack() -> appStore.clearServerMenu()`,权限码同步清空。
|
||||||
|
- Hook 仅按权限码精确匹配,不依赖用户角色;未新增测试文件,未修改生产固定密钥或无关路由。
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
- 全仓类型检查仍因已确认的 827 条基线错误返回退出码 2;本任务四个改动文件错误为 0,生产构建成功。
|
||||||
14
src/hooks/usePermissionCodes.ts
Normal file
14
src/hooks/usePermissionCodes.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAppStore } from '@/store'
|
||||||
|
|
||||||
|
export default function usePermissionCodes() {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const permissionCodes = computed(() => appStore.permissionCodes as string[])
|
||||||
|
|
||||||
|
const hasPermission = (code: string) => permissionCodes.value.includes(code)
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissionCodes,
|
||||||
|
hasPermission,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export interface ServerMenuItem extends TreeNodeBase {
|
|||||||
title?: string // 菜单标题
|
title?: string // 菜单标题
|
||||||
title_en?: string // 英文标题
|
title_en?: string // 英文标题
|
||||||
code?: string // 菜单编码
|
code?: string // 菜单编码
|
||||||
|
type?: number // 1:菜单,2:按钮
|
||||||
menu_path?: string // 菜单路径,如 '/overview'
|
menu_path?: string // 菜单路径,如 '/overview'
|
||||||
component?: string // 组件路径,如 'ops/pages/overview'
|
component?: string // 组件路径,如 'ops/pages/overview'
|
||||||
icon?: string
|
icon?: string
|
||||||
@@ -31,7 +32,6 @@ export interface ServerMenuItem extends TreeNodeBase {
|
|||||||
// 预定义的视图模块映射(用于 Vite 动态导入)
|
// 预定义的视图模块映射(用于 Vite 动态导入)
|
||||||
const viewModules = import.meta.glob('@/views/**/*.vue')
|
const viewModules = import.meta.glob('@/views/**/*.vue')
|
||||||
|
|
||||||
console.log('viewModules', viewModules)
|
|
||||||
/**
|
/**
|
||||||
* 动态加载视图组件
|
* 动态加载视图组件
|
||||||
* @param componentPath 组件路径,如 'ops/pages/overview' 或 'ops/pages/overview/index'
|
* @param componentPath 组件路径,如 'ops/pages/overview' 或 'ops/pages/overview/index'
|
||||||
@@ -76,6 +76,8 @@ export function transformMenuToRoutes(menuItems: ServerMenuItem[]): AppRouteReco
|
|||||||
const routes: AppRouteRecordRaw[] = []
|
const routes: AppRouteRecordRaw[] = []
|
||||||
|
|
||||||
for (const item of menuItems) {
|
for (const item of menuItems) {
|
||||||
|
if (item.type !== 1) continue
|
||||||
|
|
||||||
// 根据 is_full 决定如何设置 component
|
// 根据 is_full 决定如何设置 component
|
||||||
let routeComponent: AppRouteRecordRaw['component']
|
let routeComponent: AppRouteRecordRaw['component']
|
||||||
|
|
||||||
@@ -198,42 +200,44 @@ function transformChildRoutes(
|
|||||||
parentPath?: string,
|
parentPath?: string,
|
||||||
parentIsFull?: boolean
|
parentIsFull?: boolean
|
||||||
): AppRouteRecordRaw[] {
|
): AppRouteRecordRaw[] {
|
||||||
return children.map((child) => {
|
return children
|
||||||
const childFullPath = String(child.menu_path ?? child.path ?? '').trim()
|
.filter((child) => child.type === 1)
|
||||||
|
.map((child) => {
|
||||||
|
const childFullPath = String(child.menu_path ?? child.path ?? '').trim()
|
||||||
|
|
||||||
// 已配置 component 的菜单绝不覆盖;仅对许可页做路径/code 兜底,避免 includes 误匹配
|
// 已配置 component 的菜单绝不覆盖;仅对许可页做路径/code 兜底,避免 includes 误匹配
|
||||||
let componentPath = child.component || parentComponent
|
let componentPath = child.component || parentComponent
|
||||||
if (!child.component && (isLicenseCenterMenuPath(childFullPath) || child.code === 'LicenseCenter')) {
|
if (!child.component && (isLicenseCenterMenuPath(childFullPath) || child.code === 'LicenseCenter')) {
|
||||||
componentPath = LICENSE_CENTER_VIEW
|
componentPath = LICENSE_CENTER_VIEW
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativePath = extractRelativePath(childFullPath, parentPath || '')
|
const relativePath = extractRelativePath(childFullPath, parentPath || '')
|
||||||
|
|
||||||
const route: AppRouteRecordRaw = {
|
const route: AppRouteRecordRaw = {
|
||||||
path: relativePath,
|
path: relativePath,
|
||||||
name: child.title || child.name || `menu_${child.id}`,
|
name: child.title || child.name || `menu_${child.id}`,
|
||||||
meta: {
|
meta: {
|
||||||
...child,
|
...child,
|
||||||
locale: child.locale || child.title,
|
locale: child.locale || child.title,
|
||||||
requiresAuth: child.requiresAuth !== false,
|
requiresAuth: child.requiresAuth !== false,
|
||||||
roles: child.roles,
|
roles: child.roles,
|
||||||
hideInMenu: child.hideInMenu || child.hide_menu,
|
hideInMenu: child.hideInMenu || child.hide_menu,
|
||||||
},
|
},
|
||||||
component: componentPath ? loadViewComponent(componentPath) : () => import('@/views/redirect/index.vue'),
|
component: componentPath ? loadViewComponent(componentPath) : () => import('@/views/redirect/index.vue'),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 递归处理子菜单的子菜单
|
// 递归处理子菜单的子菜单
|
||||||
if (child.children && child.children.length > 0) {
|
if (child.children && child.children.length > 0) {
|
||||||
route.children = transformChildRoutes(
|
route.children = transformChildRoutes(
|
||||||
child.children,
|
child.children,
|
||||||
child.component || parentComponent,
|
child.component || parentComponent,
|
||||||
childFullPath, // 传递当前子菜单的完整路径作为下一层的父路径
|
childFullPath, // 传递当前子菜单的完整路径作为下一层的父路径
|
||||||
child.is_full || parentIsFull // 传递 is_full 标志
|
child.is_full || parentIsFull // 传递 is_full 标志
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return route
|
return route
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 本地菜单数据 - 接口未准备好时使用
|
// 本地菜单数据 - 接口未准备好时使用
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { Notification } from '@arco-design/web-vue'
|
|
||||||
import type { NotificationReturn } from '@arco-design/web-vue/es/notification/interface'
|
|
||||||
import type { RouteRecordNormalized } from 'vue-router'
|
import type { RouteRecordNormalized } from 'vue-router'
|
||||||
import defaultSettings from '@/config/settings.json'
|
import defaultSettings from '@/config/settings.json'
|
||||||
import { userPmn } from '@/api/module/user'
|
import { userPmn } from '@/api/module/user'
|
||||||
import { localMenuData, transformMenuToRoutes, type ServerMenuItem } from '@/router/menu-data'
|
import { transformMenuToRoutes, type ServerMenuItem } from '@/router/menu-data'
|
||||||
import { buildTree } from '@/utils/tree'
|
import { buildTree } from '@/utils/tree'
|
||||||
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
|
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { AppState } from './types'
|
import { AppState } from './types'
|
||||||
|
|
||||||
const useAppStore = defineStore('app', {
|
const useAppStore = defineStore('app', {
|
||||||
state: (): AppState => ({ ...defaultSettings }),
|
state: (): AppState => ({ ...defaultSettings, permissionCodes: [] }),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
appCurrentSetting(state: AppState): AppState {
|
appCurrentSetting(state: AppState): AppState {
|
||||||
@@ -50,22 +48,28 @@ const useAppStore = defineStore('app', {
|
|||||||
},
|
},
|
||||||
async fetchServerMenuConfig() {
|
async fetchServerMenuConfig() {
|
||||||
const userInfo = SafeStorage.get(AppStorageKey.USER_INFO) as any
|
const userInfo = SafeStorage.get(AppStorageKey.USER_INFO) as any
|
||||||
let notifyInstance: NotificationReturn | null = null
|
|
||||||
try {
|
try {
|
||||||
// 使用本地菜单数据(接口未准备好)
|
// 使用本地菜单数据(接口未准备好)
|
||||||
// TODO: 接口准备好后,取消下面的注释,使用真实接口数据
|
// TODO: 接口准备好后,取消下面的注释,使用真实接口数据
|
||||||
const res = await userPmn({ id: userInfo.user_id, workspace: import.meta.env.VITE_APP_WORKSPACE })
|
const res = await userPmn({ id: userInfo.user_id, workspace: import.meta.env.VITE_APP_WORKSPACE })
|
||||||
console.log('res', res)
|
|
||||||
if (res.code === 0 && res?.details?.length) {
|
if (res.code === 0 && res?.details?.length) {
|
||||||
|
const permissions = (res.details[0].permissions ?? []) as ServerMenuItem[]
|
||||||
|
|
||||||
|
this.permissionCodes = [
|
||||||
|
...new Set(
|
||||||
|
permissions.map((permission) => permission.code).filter((code): code is string => typeof code === 'string' && code.length > 0)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const menuPermissions = permissions.filter((permission) => permission.type === 1)
|
||||||
|
|
||||||
// 使用 buildTree 将扁平数据构建为树结构
|
// 使用 buildTree 将扁平数据构建为树结构
|
||||||
const treeResult = buildTree(res.details[0].permissions as ServerMenuItem[], {
|
const treeResult = buildTree(menuPermissions, {
|
||||||
orderKey: 'order',
|
orderKey: 'order',
|
||||||
})
|
})
|
||||||
console.log('buildTree', treeResult)
|
|
||||||
|
|
||||||
// 使用 transformMenuToRoutes 将树结构转换为路由配置
|
// 使用 transformMenuToRoutes 将树结构转换为路由配置
|
||||||
const routes = transformMenuToRoutes(treeResult.rootItems as ServerMenuItem[])
|
const routes = transformMenuToRoutes(treeResult.rootItems as ServerMenuItem[])
|
||||||
console.log('transformMenuToRoutes', routes)
|
|
||||||
|
|
||||||
// 动态注册路由
|
// 动态注册路由
|
||||||
routes.forEach((route) => {
|
routes.forEach((route) => {
|
||||||
@@ -105,6 +109,7 @@ const useAppStore = defineStore('app', {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
this.serverMenu = []
|
this.serverMenu = []
|
||||||
|
this.permissionCodes = []
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface AppState {
|
|||||||
tabBar: boolean
|
tabBar: boolean
|
||||||
menuFromServer: boolean
|
menuFromServer: boolean
|
||||||
serverMenu: RouteRecordNormalized[]
|
serverMenu: RouteRecordNormalized[]
|
||||||
|
permissionCodes: string[]
|
||||||
workspace?: string
|
workspace?: string
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user