diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md new file mode 100644 index 0000000..19a3af0 --- /dev/null +++ b/.superpowers/sdd/task-3-report.md @@ -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,生产构建成功。 diff --git a/src/hooks/usePermissionCodes.ts b/src/hooks/usePermissionCodes.ts new file mode 100644 index 0000000..74ae4d8 --- /dev/null +++ b/src/hooks/usePermissionCodes.ts @@ -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, + } +} diff --git a/src/router/menu-data.ts b/src/router/menu-data.ts index 05007d2..a3f6131 100644 --- a/src/router/menu-data.ts +++ b/src/router/menu-data.ts @@ -12,6 +12,7 @@ export interface ServerMenuItem extends TreeNodeBase { title?: string // 菜单标题 title_en?: string // 英文标题 code?: string // 菜单编码 + type?: number // 1:菜单,2:按钮 menu_path?: string // 菜单路径,如 '/overview' component?: string // 组件路径,如 'ops/pages/overview' icon?: string @@ -31,7 +32,6 @@ export interface ServerMenuItem extends TreeNodeBase { // 预定义的视图模块映射(用于 Vite 动态导入) const viewModules = import.meta.glob('@/views/**/*.vue') -console.log('viewModules', viewModules) /** * 动态加载视图组件 * @param componentPath 组件路径,如 'ops/pages/overview' 或 'ops/pages/overview/index' @@ -76,6 +76,8 @@ export function transformMenuToRoutes(menuItems: ServerMenuItem[]): AppRouteReco const routes: AppRouteRecordRaw[] = [] for (const item of menuItems) { + if (item.type !== 1) continue + // 根据 is_full 决定如何设置 component let routeComponent: AppRouteRecordRaw['component'] @@ -198,42 +200,44 @@ function transformChildRoutes( parentPath?: string, parentIsFull?: boolean ): AppRouteRecordRaw[] { - return children.map((child) => { - const childFullPath = String(child.menu_path ?? child.path ?? '').trim() + return children + .filter((child) => child.type === 1) + .map((child) => { + const childFullPath = String(child.menu_path ?? child.path ?? '').trim() - // 已配置 component 的菜单绝不覆盖;仅对许可页做路径/code 兜底,避免 includes 误匹配 - let componentPath = child.component || parentComponent - if (!child.component && (isLicenseCenterMenuPath(childFullPath) || child.code === 'LicenseCenter')) { - componentPath = LICENSE_CENTER_VIEW - } + // 已配置 component 的菜单绝不覆盖;仅对许可页做路径/code 兜底,避免 includes 误匹配 + let componentPath = child.component || parentComponent + if (!child.component && (isLicenseCenterMenuPath(childFullPath) || child.code === 'LicenseCenter')) { + componentPath = LICENSE_CENTER_VIEW + } - const relativePath = extractRelativePath(childFullPath, parentPath || '') + const relativePath = extractRelativePath(childFullPath, parentPath || '') - const route: AppRouteRecordRaw = { - path: relativePath, - name: child.title || child.name || `menu_${child.id}`, - meta: { - ...child, - locale: child.locale || child.title, - requiresAuth: child.requiresAuth !== false, - roles: child.roles, - hideInMenu: child.hideInMenu || child.hide_menu, - }, - component: componentPath ? loadViewComponent(componentPath) : () => import('@/views/redirect/index.vue'), - } + const route: AppRouteRecordRaw = { + path: relativePath, + name: child.title || child.name || `menu_${child.id}`, + meta: { + ...child, + locale: child.locale || child.title, + requiresAuth: child.requiresAuth !== false, + roles: child.roles, + hideInMenu: child.hideInMenu || child.hide_menu, + }, + component: componentPath ? loadViewComponent(componentPath) : () => import('@/views/redirect/index.vue'), + } - // 递归处理子菜单的子菜单 - if (child.children && child.children.length > 0) { - route.children = transformChildRoutes( - child.children, - child.component || parentComponent, - childFullPath, // 传递当前子菜单的完整路径作为下一层的父路径 - child.is_full || parentIsFull // 传递 is_full 标志 - ) - } + // 递归处理子菜单的子菜单 + if (child.children && child.children.length > 0) { + route.children = transformChildRoutes( + child.children, + child.component || parentComponent, + childFullPath, // 传递当前子菜单的完整路径作为下一层的父路径 + child.is_full || parentIsFull // 传递 is_full 标志 + ) + } - return route - }) + return route + }) } // 本地菜单数据 - 接口未准备好时使用 diff --git a/src/store/modules/app/index.ts b/src/store/modules/app/index.ts index 769ad75..603b9b3 100644 --- a/src/store/modules/app/index.ts +++ b/src/store/modules/app/index.ts @@ -1,17 +1,15 @@ 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 defaultSettings from '@/config/settings.json' 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 SafeStorage, { AppStorageKey } from '@/utils/safeStorage' import router from '@/router' import { AppState } from './types' const useAppStore = defineStore('app', { - state: (): AppState => ({ ...defaultSettings }), + state: (): AppState => ({ ...defaultSettings, permissionCodes: [] }), getters: { appCurrentSetting(state: AppState): AppState { @@ -50,22 +48,28 @@ const useAppStore = defineStore('app', { }, async fetchServerMenuConfig() { const userInfo = SafeStorage.get(AppStorageKey.USER_INFO) as any - let notifyInstance: NotificationReturn | null = null try { // 使用本地菜单数据(接口未准备好) // TODO: 接口准备好后,取消下面的注释,使用真实接口数据 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) { + 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 将扁平数据构建为树结构 - const treeResult = buildTree(res.details[0].permissions as ServerMenuItem[], { + const treeResult = buildTree(menuPermissions, { orderKey: 'order', }) - console.log('buildTree', treeResult) // 使用 transformMenuToRoutes 将树结构转换为路由配置 const routes = transformMenuToRoutes(treeResult.rootItems as ServerMenuItem[]) - console.log('transformMenuToRoutes', routes) // 动态注册路由 routes.forEach((route) => { @@ -105,6 +109,7 @@ const useAppStore = defineStore('app', { }) } this.serverMenu = [] + this.permissionCodes = [] }, }, }) diff --git a/src/store/modules/app/types.ts b/src/store/modules/app/types.ts index d5fc9dd..b6a4fe3 100644 --- a/src/store/modules/app/types.ts +++ b/src/store/modules/app/types.ts @@ -16,6 +16,7 @@ export interface AppState { tabBar: boolean menuFromServer: boolean serverMenu: RouteRecordNormalized[] + permissionCodes: string[] workspace?: string [key: string]: unknown }