From d6bb3bd43e5b4d6dbd158797cef5ffa9d3bc8b39 Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Mon, 17 Aug 2026 21:56:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=B9=B3=E5=8F=B0=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E8=8F=9C=E5=8D=95=E6=A0=91=E4=B8=8E=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../logic/platform/platform/access.go | 26 +++- .../logic/platform/platform/access_test.go | 14 ++ .../logic/platform/platform/role_menu.go | 20 ++- .../logic/platform/platform/role_menu_test.go | 28 ++++ docs/05-平台总后台需求.md | 6 + ...志_平台角色菜单树与授权导航修复_20260817.md | 41 ++++++ ...日志_平台账户登录后误入404修复_20260817.md | 51 +++++++ docs/项目文档_平台账户授权登录落点_v1.0.md | 36 +++++ frontend/platform_admin/package.json | 2 + .../check-authorized-login-landing.mjs | 127 ++++++++++++++++++ .../scripts/check-menu-permission-tree.mjs | 26 ++++ .../platform_admin/src/api/resource-form.ts | 5 +- frontend/platform_admin/src/api/resources.ts | 3 +- .../src/router/authorized-landing.ts | 85 ++++++++++++ .../platform_admin/src/router/constants.ts | 1 + frontend/platform_admin/src/router/index.ts | 7 +- .../platform_admin/src/router/routes/base.ts | 11 ++ .../src/views/login/components/login-form.vue | 26 +++- .../src/views/no-permission/index.vue | 78 +++++++++++ .../src/views/resource/MenuPermissionTree.vue | 127 ++++++++++++++++++ .../views/resource/ResourceActionDialog.vue | 29 +++- .../src/views/resource/ResourceFieldForm.vue | 7 + 22 files changed, 739 insertions(+), 17 deletions(-) create mode 100644 backend/api/internal/logic/platform/platform/role_menu_test.go create mode 100644 docs/操作日志_平台角色菜单树与授权导航修复_20260817.md create mode 100644 docs/操作日志_平台账户登录后误入404修复_20260817.md create mode 100644 docs/项目文档_平台账户授权登录落点_v1.0.md create mode 100644 frontend/platform_admin/scripts/check-authorized-login-landing.mjs create mode 100644 frontend/platform_admin/scripts/check-menu-permission-tree.mjs create mode 100644 frontend/platform_admin/src/router/authorized-landing.ts create mode 100644 frontend/platform_admin/src/views/no-permission/index.vue create mode 100644 frontend/platform_admin/src/views/resource/MenuPermissionTree.vue diff --git a/backend/api/internal/logic/platform/platform/access.go b/backend/api/internal/logic/platform/platform/access.go index 8372e88..665afe3 100644 --- a/backend/api/internal/logic/platform/platform/access.go +++ b/backend/api/internal/logic/platform/platform/access.go @@ -59,6 +59,20 @@ func platformMenuAllowsRequest(menus []platformbase.Menu, requestPath, method st return false } +// isPlatformSelfMenuListRequest 判断是否为当前账号读取自身授权导航的基础请求。 +// 该接口的响应仍会按当前角色过滤,不授予菜单配置详情或写权限。 +func isPlatformSelfMenuListRequest(requestPath, method string) bool { + if method != "GET" { + return false + } + marker := "/platform/v1/" + index := strings.Index(requestPath, marker) + if index < 0 { + return false + } + return strings.Trim(requestPath[index+len(marker):], "/") == "platform_menu" +} + func platformRouteMenuIdentity(resource string) string { switch { case resource == "dashboard": @@ -114,7 +128,17 @@ func RequirePlatformMenuAccess() gin.HandlerFunc { return } menus, err := platformbase.LoadPlatformMenus(claims.Role) - if err != nil || !platformMenuAllowsRequest(menus, ctx.Request.URL.Path, ctx.Request.Method) || + if err != nil { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + ctx.Abort() + return + } + if isPlatformSelfMenuListRequest(ctx.Request.URL.Path, ctx.Request.Method) { + ctx.Set(platformMenusContextKey, menus) + ctx.Next() + return + } + if !platformMenuAllowsRequest(menus, ctx.Request.URL.Path, ctx.Request.Method) || !platformScopedRequestAllowed(ctx, menus) { infra.Response.Error(ctx, errcode.ErrPermissionDenied) ctx.Abort() diff --git a/backend/api/internal/logic/platform/platform/access_test.go b/backend/api/internal/logic/platform/platform/access_test.go index bf53aa2..97f0b4d 100644 --- a/backend/api/internal/logic/platform/platform/access_test.go +++ b/backend/api/internal/logic/platform/platform/access_test.go @@ -6,6 +6,20 @@ import ( platformbase "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform" ) +// TestPlatformSelfMenuListRequest 保障普通账号只能免菜单能力读取自己的导航列表。 +func TestPlatformSelfMenuListRequest(t *testing.T) { + path := "/heqi/platform/v1/platform_menu" + if !isPlatformSelfMenuListRequest(path, "GET") { + t.Fatal("平台账号应可读取自己的授权菜单列表") + } + if isPlatformSelfMenuListRequest(path, "POST") { + t.Fatal("菜单写请求不得通过自身导航读取规则放行") + } + if isPlatformSelfMenuListRequest(path+"/platform_menu", "GET") { + t.Fatal("菜单详情不得通过自身导航读取规则放行") + } +} + func TestSecondLevelMenuPermissionDoesNotGrantSibling(t *testing.T) { menus := []platformbase.Menu{{Identity: "delivery_basic"}} if !platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_basic") { diff --git a/backend/api/internal/logic/platform/platform/role_menu.go b/backend/api/internal/logic/platform/platform/role_menu.go index ab8505b..49fd90f 100644 --- a/backend/api/internal/logic/platform/platform/role_menu.go +++ b/backend/api/internal/logic/platform/platform/role_menu.go @@ -18,7 +18,7 @@ type platformRoleMenusRequest struct { MenuIdentities []string `json:"menu_identities"` } -// ReplacePlatformRoleMenus replaces every menu assignment for a role atomically. +// ReplacePlatformRoleMenus 以事务整体替换角色的叶子菜单权限。 func ReplacePlatformRoleMenus(ctx *gin.Context) { if !common.RequirePlatformRoot(ctx) { return @@ -42,7 +42,8 @@ func ReplacePlatformRoleMenus(ctx *gin.Context) { if !ok { return gorm.ErrRecordNotFound } - if menu.ParentIdentity != "" { + // 有子菜单的父节点仅用于导航聚合;无子菜单的顶级页面仍是有效业务权限。 + if platformMenuIsAssignable(menu) { menuIdentities[menu.Identity] = struct{}{} } } @@ -71,7 +72,7 @@ func ReplacePlatformRoleMenus(ctx *gin.Context) { infra.Response.Success(ctx, gin.H{"updated": true}) } -// ListPlatformRoleMenuIdentities returns the current assignment for the role editor. +// ListPlatformRoleMenuIdentities 返回角色编辑器当前保存的叶子菜单标识。 func ListPlatformRoleMenuIdentities(ctx *gin.Context) { if !common.RequirePlatformRoot(ctx) { return @@ -90,3 +91,16 @@ func ListPlatformRoleMenuIdentities(ctx *gin.Context) { } infra.Response.Success(ctx, gin.H{"menu_identities": identities}) } + +// platformMenuIsAssignable 区分导航分组和可持久化的业务页面权限。 +func platformMenuIsAssignable(menu platformbase.Menu) bool { + if menu.Status != common.StatusEnable { + return false + } + for _, candidate := range platformbase.AllPlatformMenus() { + if candidate.ParentIdentity == menu.Identity { + return false + } + } + return true +} diff --git a/backend/api/internal/logic/platform/platform/role_menu_test.go b/backend/api/internal/logic/platform/platform/role_menu_test.go new file mode 100644 index 0000000..a15943b --- /dev/null +++ b/backend/api/internal/logic/platform/platform/role_menu_test.go @@ -0,0 +1,28 @@ +package platform + +import ( + "testing" + + platformbase "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform" +) + +// TestPlatformMenuIsAssignable 验证导航父级不落库,独立顶级页面和普通叶子可以保存。 +func TestPlatformMenuIsAssignable(t *testing.T) { + tests := []struct { + identity string + want bool + }{ + {identity: "organization", want: false}, + {identity: "gas_basic", want: true}, + {identity: "dashboard_overview", want: true}, + } + for _, test := range tests { + menu, ok := platformbase.FindPlatformMenu(test.identity) + if !ok { + t.Fatalf("测试菜单不存在:%s", test.identity) + } + if got := platformMenuIsAssignable(menu); got != test.want { + t.Fatalf("菜单 %s 可分配状态错误:got=%v want=%v", test.identity, got, test.want) + } + } +} diff --git a/docs/05-平台总后台需求.md b/docs/05-平台总后台需求.md index aa72dde..8e5047c 100644 --- a/docs/05-平台总后台需求.md +++ b/docs/05-平台总后台需求.md @@ -76,6 +76,8 @@ - 登录成功返回 JWT、账户 `identity`、显示名称和角色编码。 - `Authorization` 请求头当前直接传 JWT 原始值,不使用 `Bearer` 前缀。 - 已登录账户可查看个人资料、角色和菜单编码。 +- 登录后优先恢复当前角色有权访问的历史叶子页;历史地址无权或不存在时,按服务端菜单顺序进入首个可访问叶子页,不将父级分组或隐藏详情页作为默认落点。 +- 已认证账户未分配任何业务菜单时,展示“尚未分配菜单权限”引导页并提供重新加载权限和退出登录,不回退为 404。 - 修改密码必须校验当前密码,新密码不少于 6 位。 - 系统启动时幂等创建 `root` 管理账户;root 初始密码优先读取环境变量 `HEQI_PLATFORM_ROOT_PASSWORD`。 @@ -83,6 +85,10 @@ - `root` 角色拥有全部平台菜单能力。 - 非 root 账户必须关联处于启用状态的平台角色,并按角色菜单逐请求鉴权。 +- 已认证平台账户可读取当前角色自己的授权菜单树;该基础能力不等同于平台菜单配置权限,不得借此读取菜单详情或执行菜单写操作。 +- 角色菜单使用父子树分配:父菜单仅承担导航分组和全选、全清,部分子菜单选中时显示半选;服务端只持久化有效叶子菜单,允许清空角色全部菜单权限。 +- 菜单搜索和已选内容使用“父菜单 / 子菜单”完整路径,避免同名业务菜单产生歧义。 +- 角色权限调整后,服务端从下一次请求开始按新权限鉴权;已登录账户可通过重新加载权限或重新登录刷新前端导航。 - 创建工作人员、安装人员、配送人员和运维人员是相互独立的菜单能力。 - 工作人员资质权限跟随所属人员角色,不能凭任一人员菜单访问其他类型人员资质。 - 创建配送订单使用独立的 `gasorder_create` 权限,不因拥有订单列表权限自动获得。 diff --git a/docs/操作日志_平台角色菜单树与授权导航修复_20260817.md b/docs/操作日志_平台角色菜单树与授权导航修复_20260817.md new file mode 100644 index 0000000..f8e45ae --- /dev/null +++ b/docs/操作日志_平台角色菜单树与授权导航修复_20260817.md @@ -0,0 +1,41 @@ +# 操作日志:平台角色菜单树与授权导航修复 + +操作时间:2026-08-17 +操作类型:修改 +影响模块:平台总后台角色菜单、平台账号登录导航、服务端菜单鉴权 + +## 操作前状态 + +- 角色菜单使用扁平多选框,父菜单与叶子菜单看似可以独立选择。 +- 服务端实际只保存叶子菜单,导致父菜单勾选不落库、子菜单选中时父菜单不显示半选。 +- 普通账号取得个人菜单编码后仍需读取 `/platform_menu`,但该请求又要求“平台菜单”业务权限,形成权限死循环并错误进入无权限页。 + +## 具体操作 + +- 新增专用角色菜单树,父菜单支持全选、全清和半选展示,子菜单显示完整路径。 +- 表单只提交叶子菜单;允许清空全部权限,并在提交前二次警告。 +- 放行已认证平台账号读取当前角色自己的菜单列表,响应继续按角色过滤;菜单详情和写操作不放宽。 +- 服务端忽略具有子菜单的导航父节点,同时保留无子菜单顶级页面作为有效业务权限。 +- 补充后端访问规则测试和前端静态契约检查。 + +## 行为变化 + +- 修改前:父子菜单状态不一致,普通业务角色可能已分配权限但无法加载导航。 +- 修改后:父菜单只表达叶子权限的聚合状态;普通账号可加载自己的授权导航,未授权兄弟菜单仍不可见、不可访问。 + +## 风险评估 + +- 不修改数据库结构和公共写接口,历史叶子数据继续兼容。 +- `GET /platform_menu` 仅放宽为“读取自身授权导航”,服务端仍根据 JWT 角色过滤,不开放详情和写权限。 +- 清空权限会使该角色账号无法进入业务页,前端已增加明确确认提示。 + +## 验证结果 + +- 后端 `go test ./internal/logic/platform/platform ./internal/logic/platform ./internal/routers`:通过。 +- 前端 `pnpm run menu-permission-tree:check`:通过。 +- 前端 `pnpm run auth-landing:check`:通过。 +- 前端 `pnpm run type:check`:通过。 +- 前端 `pnpm run build`:通过。 +- Codex 浏览器人工验证:父级半选、全选、全清、完整路径搜索和清空权限二次确认均通过;测试过程已取消弹窗,未写入角色权限。 +- 浏览器复验修正了菜单数据返回前数组未初始化的瞬时 Vue 警告;修正后无新增控制台警告。 +- 当前平台账户列表没有普通角色账号,因此未创建临时数据,也未执行普通账号真实登录;该链路由后端授权测试和前端登录落点检查覆盖。 diff --git a/docs/操作日志_平台账户登录后误入404修复_20260817.md b/docs/操作日志_平台账户登录后误入404修复_20260817.md new file mode 100644 index 0000000..423c387 --- /dev/null +++ b/docs/操作日志_平台账户登录后误入404修复_20260817.md @@ -0,0 +1,51 @@ +# 平台账户登录后误入 404 修复操作日志 + +操作时间:2026-08-17 +操作类型:修改、扩展 +影响模块:平台总后台 / 认证 / 角色菜单 / 前端路由 + +## 操作前状态 + +- 所有账号登录成功后均固定进入 `dashboard-overview`。 +- 非 root 角色未分配数据概览时,路由守卫正确拒绝访问,但页面错误显示英文 404。 +- 完全没有菜单的账号没有可恢复引导。 + +## 具体操作 + +1. 登录成功后先加载个人资料与当前角色菜单。 +2. 只恢复有权且可直接落地的历史叶子路由。 +3. 历史地址不可用时,进入已授权菜单树中的首个可见叶子页。 +4. 新增“尚未分配菜单权限”页,支持重新加载和退出。 +5. 增加可执行的登录落点回归脚本。 + +## 操作后状态 + +- root 仍按菜单顺序进入数据概览。 +- 非 root 账号进入自身首个授权业务页。 +- 无权历史地址不会绕过鉴权,也不再导致登录后 404。 +- 零菜单账号获得明确中文说明和恢复入口。 + +## 代码变更 + +- `frontend/platform_admin/src/router/authorized-landing.ts` +- `frontend/platform_admin/src/router/routes/base.ts` +- `frontend/platform_admin/src/router/index.ts` +- `frontend/platform_admin/src/router/constants.ts` +- `frontend/platform_admin/src/views/login/components/login-form.vue` +- `frontend/platform_admin/src/views/no-permission/index.vue` +- `frontend/platform_admin/scripts/check-authorized-login-landing.mjs` +- `frontend/platform_admin/package.json` + +## 验证结果 + +- `pnpm auth-landing:check`:通过,覆盖有权历史页、无权历史页、父级分组和零菜单四条路径。 +- `pnpm contract:check`:通过,48 个资源契约正常。 +- `pnpm resource-display-contracts:check`:通过。 +- `pnpm type:check`:通过。 +- `pnpm build`:通过,新增权限引导页已生成独立产物。 +- Biome 定向检查退出码为 0;Vue 模板引用仍有工具级未使用警告,本次新页面组件名已显式声明。 + +## 风险评估 + +- 不修改登录接口、令牌格式、角色菜单表或后端鉴权,公共接口保持兼容。 +- 登录阶段增加一次已有资料请求,用于在跳转前取得真实菜单权限。 diff --git a/docs/项目文档_平台账户授权登录落点_v1.0.md b/docs/项目文档_平台账户授权登录落点_v1.0.md new file mode 100644 index 0000000..1b3e7b3 --- /dev/null +++ b/docs/项目文档_平台账户授权登录落点_v1.0.md @@ -0,0 +1,36 @@ +# 项目文档:平台账户授权登录落点 v1.0 + +## 1. 项目概述 + +平台总后台使用 Vue 3、Vue Router、Pinia 和 Arco Design Vue。本功能使非 root 账号在登录后进入角色实际获授权的首个业务页,不再默认强制进入数据概览。 + +## 2. 目录结构 + +```text +frontend/platform_admin/ +├── src/router/authorized-landing.ts # 授权历史地址和首个叶子菜单选择 +├── src/router/routes/base.ts # 零菜单权限页路由 +├── src/router/constants.ts # 权限守卫允许的引导页 +├── src/views/login/components/login-form.vue # 登录后加载资料并选择落点 +├── src/views/no-permission/index.vue # 尚未分配菜单权限引导 +└── scripts/check-authorized-login-landing.mjs # 登录落点回归检查 +``` + +## 3. 核心规则 + +1. 登录成功后立即读取当前账号资料和服务端菜单。 +2. 登录前历史地址是有权叶子页时恢复该地址。 +3. 历史地址无权、不存在或是父级分组时,从服务端已过滤菜单树中选择首个非隐藏叶子页。 +4. 角色没有任何可访问菜单时进入独立 403 引导页,可在 root 分配权限后重新加载,也可退出登录。 +5. 直接猜测未授权业务地址仍由前后端权限守卫拒绝,登录落点不扩大角色权限。 + +## 4. 变更记录 + +- v1.0:新增授权登录落点解析、历史地址安全恢复和零菜单权限引导页。 +- 不修改 JWT、平台角色数据、菜单分配接口和后端鉴权中间件。 + +## 5. 维护指南 + +- 新增一级菜单时必须保持服务端排序、前端路由 `menuCode` 和叶子页名称一致。 +- 不得将父级路由的硬编码重定向作为非 root 默认首页。 +- 调整登录或菜单时至少执行 `pnpm auth-landing:check`、`pnpm type:check` 和 `pnpm build`。 diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index 8c246fd..4e25854 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -16,6 +16,8 @@ "resource-pages:check": "node scripts/check-resource-pages.mjs", "account-roles:check": "node scripts/check-account-role-presentation.mjs", "avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs", + "auth-landing:check": "node scripts/check-authorized-login-landing.mjs", + "menu-permission-tree:check": "node scripts/check-menu-permission-tree.mjs", "contract-attachment:check": "node scripts/check-contract-attachment-control.mjs", "contract-product-entry:check": "node scripts/check-contract-product-entry.mjs", "contract-product-filter:check": "node scripts/check-contract-product-filter.mjs", diff --git a/frontend/platform_admin/scripts/check-authorized-login-landing.mjs b/frontend/platform_admin/scripts/check-authorized-login-landing.mjs new file mode 100644 index 0000000..4630e9c --- /dev/null +++ b/frontend/platform_admin/scripts/check-authorized-login-landing.mjs @@ -0,0 +1,127 @@ +/** + * 功能:校验非 root 账号登录落点、历史地址恢复与零菜单降级行为。 + * 版本:v1.0.0 + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(currentDirectory, '..'); +const source = fs.readFileSync( + path.join(root, 'src/router/authorized-landing.ts'), + 'utf8', +); +const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2020, + }, +}).outputText; +const landing = await import( + `data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}` +); + +const menuTree = [ + { + name: 'finance', + meta: { menuCode: 'finance' }, + children: [ + { + name: 'finance-hidden', + meta: { menuCode: 'finance', hideInMenu: true }, + }, + { + name: 'finance-payments', + meta: { menuCode: 'fin_payment' }, + }, + ], + }, +]; + +assert.deepEqual(landing.findFirstAuthorizedMenuRoute(menuTree), { + name: 'finance-payments', +}); +assert.equal(landing.findFirstAuthorizedMenuRoute([]), null); + +function fakeRouter(routeByTarget) { + return { + resolve(target) { + return routeByTarget(String(target.name ?? target.path ?? '')); + }, + }; +} + +const router = fakeRouter((target) => { + if (target === 'finance') { + return { + name: 'finance', + meta: { menuCode: 'finance' }, + matched: [{ redirect: '/finance/payments', children: [{}] }], + }; + } + return { + name: target, + meta: { + menuCode: + target === 'finance-payments' ? 'fin_payment' : 'dashboard_overview', + }, + matched: [{ children: [] }], + }; +}); + +assert.deepEqual( + landing.resolveAuthorizedLanding( + router, + menuTree, + 'finance_operator', + ['finance', 'fin_payment'], + { name: 'finance-payments' }, + ), + { name: 'finance-payments' }, +); +assert.deepEqual( + landing.resolveAuthorizedLanding( + router, + menuTree, + 'finance_operator', + ['finance', 'fin_payment'], + { name: 'dashboard-overview' }, + ), + { name: 'finance-payments' }, +); +assert.deepEqual( + landing.resolveAuthorizedLanding( + router, + menuTree, + 'finance_operator', + ['finance', 'fin_payment'], + { name: 'finance' }, + ), + { name: 'finance-payments' }, +); +assert.deepEqual( + landing.resolveAuthorizedLanding(router, [], 'empty_role', [], undefined), + { name: 'noPermission' }, +); + +for (const [relativePath, expected] of [ + ['src/router/constants.ts', "{ name: 'noPermission', children: [] }"], + ['src/router/routes/base.ts', "path: '/no-permission'"], + [ + 'src/views/login/components/login-form.vue', + 'resolveAuthorizedLanding(', + ], + [ + 'src/views/no-permission/index.vue', + '尚未分配菜单权限', + ], +]) { + const content = fs.readFileSync(path.join(root, relativePath), 'utf8'); + assert.ok(content.includes(expected), `${relativePath} 缺少契约:${expected}`); +} + +process.stdout.write('平台账号授权登录落点检查通过\n'); diff --git a/frontend/platform_admin/scripts/check-menu-permission-tree.mjs b/frontend/platform_admin/scripts/check-menu-permission-tree.mjs new file mode 100644 index 0000000..8c5dff5 --- /dev/null +++ b/frontend/platform_admin/scripts/check-menu-permission-tree.mjs @@ -0,0 +1,26 @@ +/** + * 功能:静态检查角色菜单树、叶子提交和自身导航读取契约。 + * 版本:v1.0.0 + */ +import { readFileSync } from 'node:fs'; + +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8'); +const resources = read('../src/api/resources.ts'); +const form = read('../src/views/resource/ResourceFieldForm.vue'); +const tree = read('../src/views/resource/MenuPermissionTree.vue'); +const dialog = read('../src/views/resource/ResourceActionDialog.vue'); +const payload = read('../src/api/resource-form.ts'); + +function requireMarker(source, marker, message) { + if (!source.includes(marker)) throw new Error(`角色菜单树检查失败:${message}`); +} + +requireMarker(resources, "type: 'menu-tree'", '分配菜单未使用专用树字段'); +requireMarker(form, 'MenuPermissionTree', '通用表单未挂载菜单树'); +requireMarker(tree, ':indeterminate="groupIndeterminate(group)"', '父菜单缺少半选状态'); +requireMarker(tree, 'toggleGroup(group, checked === true)', '父菜单缺少全选或全清行为'); +requireMarker(tree, 'group.name }} / {{ child.name', '子菜单未显示完整路径'); +requireMarker(payload, "field.type === 'menu-tree'", '空叶子数组不能稳定提交'); +requireMarker(dialog, '确认清空菜单权限', '清空全部菜单前缺少明确警告'); + +console.log('角色菜单树静态检查通过'); diff --git a/frontend/platform_admin/src/api/resource-form.ts b/frontend/platform_admin/src/api/resource-form.ts index 130f11c..d8c898f 100644 --- a/frontend/platform_admin/src/api/resource-form.ts +++ b/frontend/platform_admin/src/api/resource-form.ts @@ -23,7 +23,10 @@ export function buildResourcePayload( for (const field of fields) { if (mode === 'edit' && field.type === 'password') continue; const value = form[field.key]; - if (field.type === 'identity-list' && Array.isArray(value)) { + if ( + (field.type === 'identity-list' || field.type === 'menu-tree') && + Array.isArray(value) + ) { payload[field.key] = value; continue; } diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index 4accb4e..88665a4 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -16,6 +16,7 @@ export type ResourceFieldType = | 'password' | 'identity' | 'identity-list' + | 'menu-tree' | 'number' | 'money' | 'boolean' @@ -949,7 +950,7 @@ export const resources: ResourceUiDefinition[] = [ ]), define('platform_account', '平台账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('avatar'), f('platform_role_code', { required: true, unknownValueLabel: '未知角色' }), f('phone', { emptyText: '未填写' })]), define('platform_role', '平台角色', 'writable', [f('role_code', { required: true }), f('name', { required: true }), f('location_scope', { required: true, type: 'select', options: resourceSearchEnumOptions('platform_role', 'location_scope') })], 'list', [ - { name: '分配菜单', resource: '/platform_role/:identity/menu', method: 'PUT', fields: [f('menu_identities', { type: 'identity-list', relation: '/platform_menu' })] }, + { name: '分配菜单', resource: '/platform_role/:identity/menu', method: 'PUT', fields: [f('menu_identities', { type: 'menu-tree', relation: '/platform_menu' })] }, ]), define('platform_menu', '平台菜单', 'readonly', [ relation('parent_identity', '/platform_menu'), f('group_code'), f('name'), f('icon'), diff --git a/frontend/platform_admin/src/router/authorized-landing.ts b/frontend/platform_admin/src/router/authorized-landing.ts new file mode 100644 index 0000000..cb9c145 --- /dev/null +++ b/frontend/platform_admin/src/router/authorized-landing.ts @@ -0,0 +1,85 @@ +/** + * 功能:为平台管理员选择安全的登录落点。 + * 版本:v1.0.0 + */ +import type { + RouteLocationRaw, + RouteMeta, + RouteRecordName, + Router, +} from 'vue-router'; + +export const NO_PERMISSION_ROUTE_NAME = 'noPermission'; + +export interface AuthorizedMenuRoute { + name?: RouteRecordName | null; + meta?: RouteMeta; + children?: AuthorizedMenuRoute[]; +} + +/** + * 按服务端菜单顺序寻找首个可落地的叶子页面。 + * 父级分组和隐藏详情页不能成为默认首页。 + */ +export function findFirstAuthorizedMenuRoute( + routes: readonly AuthorizedMenuRoute[], +): RouteLocationRaw | null { + for (const route of routes) { + if (route.children?.length) { + const child = findFirstAuthorizedMenuRoute(route.children); + if (child) return child; + continue; + } + if (route.name && route.meta?.hideInMenu !== true) { + return { name: route.name }; + } + } + return null; +} + +/** 仅允许恢复当前角色确实有权访问的叶子路由。 */ +export function isAuthorizedLoginTarget( + router: Router, + target: RouteLocationRaw, + role: string, + menuCodes: readonly string[], +) { + const resolved = router.resolve(target); + const terminal = resolved.matched[resolved.matched.length - 1]; + if ( + !terminal || + resolved.name === 'login' || + resolved.name === 'notFound' || + resolved.name === NO_PERMISSION_ROUTE_NAME || + terminal.redirect || + terminal.children.length + ) { + return false; + } + const menuCode = resolved.meta.menuCode; + return ( + role === 'root' || + (typeof menuCode === 'string' && menuCodes.includes(menuCode)) + ); +} + +/** 优先恢复有权历史地址,否则进入首个授权菜单或权限引导页。 */ +export function resolveAuthorizedLanding( + router: Router, + menus: readonly AuthorizedMenuRoute[], + role: string, + menuCodes: readonly string[], + requested?: RouteLocationRaw, +): RouteLocationRaw { + if ( + requested && + isAuthorizedLoginTarget(router, requested, role, menuCodes) + ) { + return requested; + } + return ( + findFirstAuthorizedMenuRoute(menus) ?? { + name: NO_PERMISSION_ROUTE_NAME, + } + ); +} diff --git a/frontend/platform_admin/src/router/constants.ts b/frontend/platform_admin/src/router/constants.ts index 7daef7f..e114298 100644 --- a/frontend/platform_admin/src/router/constants.ts +++ b/frontend/platform_admin/src/router/constants.ts @@ -1,6 +1,7 @@ export const WHITE_LIST = [ { name: 'notFound', children: [] }, { name: 'login', children: [] }, + { name: 'noPermission', children: [] }, ]; export const NOT_FOUND = { diff --git a/frontend/platform_admin/src/router/index.ts b/frontend/platform_admin/src/router/index.ts index aa7fca3..0ad666a 100644 --- a/frontend/platform_admin/src/router/index.ts +++ b/frontend/platform_admin/src/router/index.ts @@ -4,7 +4,11 @@ import 'nprogress/nprogress.css'; import createRouteGuard from './guard'; import { appRoutes } from './routes'; -import { NOT_FOUND_ROUTE, REDIRECT_MAIN } from './routes/base'; +import { + NOT_FOUND_ROUTE, + NO_PERMISSION_ROUTE, + REDIRECT_MAIN, +} from './routes/base'; NProgress.configure({ showSpinner: false }); // NProgress Configuration @@ -25,6 +29,7 @@ const router = createRouter({ }, ...appRoutes, REDIRECT_MAIN, + NO_PERMISSION_ROUTE, NOT_FOUND_ROUTE, ], scrollBehavior() { diff --git a/frontend/platform_admin/src/router/routes/base.ts b/frontend/platform_admin/src/router/routes/base.ts index 4ee02d1..cb44b63 100644 --- a/frontend/platform_admin/src/router/routes/base.ts +++ b/frontend/platform_admin/src/router/routes/base.ts @@ -24,6 +24,17 @@ export const REDIRECT_MAIN: RouteRecordRaw = { ], }; +/** 已认证但暂无业务菜单时的独立引导页。 */ +export const NO_PERMISSION_ROUTE: RouteRecordRaw = { + path: '/no-permission', + name: 'noPermission', + component: () => import('@/views/no-permission/index.vue'), + meta: { + requiresAuth: true, + hideInMenu: true, + }, +}; + export const NOT_FOUND_ROUTE: RouteRecordRaw = { path: '/:pathMatch(.*)*', name: 'notFound', diff --git a/frontend/platform_admin/src/views/login/components/login-form.vue b/frontend/platform_admin/src/views/login/components/login-form.vue index 498a20c..fed4cf2 100644 --- a/frontend/platform_admin/src/views/login/components/login-form.vue +++ b/frontend/platform_admin/src/views/login/components/login-form.vue @@ -72,9 +72,11 @@ import { reactive, ref } from 'vue'; import { useRouter } from 'vue-router'; import type { LoginData } from '@/api/auth'; import useLoading from '@/hooks/loading'; -import { useUserStore } from '@/store'; +import { resolveAuthorizedLanding } from '@/router/authorized-landing'; +import { useAppStore, useUserStore } from '@/store'; const router = useRouter(); +const appStore = useAppStore(); const errorMessage = ref(''); const { loading, setLoading } = useLoading(); const userStore = useUserStore(); @@ -102,20 +104,32 @@ const handleSubmit = async ({ setLoading(true); try { await userStore.login(values as LoginData); + await userStore.info(); const { redirect, ...othersQuery } = router.currentRoute.value.query; const storedRedirect = sessionStorage.getItem('auth-redirect'); sessionStorage.removeItem('auth-redirect'); - router.push({ - ...(storedRedirect?.startsWith('/') - ? { path: storedRedirect } - : { name: (redirect as string) || 'dashboard-overview', query: othersQuery }), - }); + const requested = storedRedirect?.startsWith('/') + ? { path: storedRedirect } + : typeof redirect === 'string' + ? { name: redirect, query: othersQuery } + : undefined; + await router.push( + resolveAuthorizedLanding( + router, + appStore.appAsyncMenus, + userStore.role, + userStore.menuCodes, + requested, + ), + ); Message.success('登录成功'); const { rememberPassword } = loginConfig.value; const { username } = values; loginConfig.value.username = rememberPassword ? username : ''; loginConfig.value.password = ''; } catch (err) { + // 登录后资料或菜单加载失败时同步撤销令牌,避免留下半登录会话。 + await userStore.logout(); // 将登录接口的已知英文错误转换为中文,未知错误保持服务端原文。 const message = (err as Error).message; const loginErrorMessages: Record = { diff --git a/frontend/platform_admin/src/views/no-permission/index.vue b/frontend/platform_admin/src/views/no-permission/index.vue new file mode 100644 index 0000000..d4dd866 --- /dev/null +++ b/frontend/platform_admin/src/views/no-permission/index.vue @@ -0,0 +1,78 @@ + + + + + + diff --git a/frontend/platform_admin/src/views/resource/MenuPermissionTree.vue b/frontend/platform_admin/src/views/resource/MenuPermissionTree.vue new file mode 100644 index 0000000..1c814af --- /dev/null +++ b/frontend/platform_admin/src/views/resource/MenuPermissionTree.vue @@ -0,0 +1,127 @@ + + + + + + diff --git a/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue b/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue index 4f43059..f098203 100644 --- a/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue +++ b/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue @@ -40,7 +40,7 @@