完善平台角色菜单树与授权登录导航
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` 权限,不因拥有订单列表权限自动获得。
|
||||
|
||||
41
docs/操作日志_平台角色菜单树与授权导航修复_20260817.md
Normal file
41
docs/操作日志_平台角色菜单树与授权导航修复_20260817.md
Normal file
@@ -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 警告;修正后无新增控制台警告。
|
||||
- 当前平台账户列表没有普通角色账号,因此未创建临时数据,也未执行普通账号真实登录;该链路由后端授权测试和前端登录落点检查覆盖。
|
||||
51
docs/操作日志_平台账户登录后误入404修复_20260817.md
Normal file
51
docs/操作日志_平台账户登录后误入404修复_20260817.md
Normal file
@@ -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 模板引用仍有工具级未使用警告,本次新页面组件名已显式声明。
|
||||
|
||||
## 风险评估
|
||||
|
||||
- 不修改登录接口、令牌格式、角色菜单表或后端鉴权,公共接口保持兼容。
|
||||
- 登录阶段增加一次已有资料请求,用于在跳转前取得真实菜单权限。
|
||||
36
docs/项目文档_平台账户授权登录落点_v1.0.md
Normal file
36
docs/项目文档_平台账户授权登录落点_v1.0.md
Normal file
@@ -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`。
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
@@ -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('角色菜单树静态检查通过');
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
85
frontend/platform_admin/src/router/authorized-landing.ts
Normal file
85
frontend/platform_admin/src/router/authorized-landing.ts
Normal file
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export const WHITE_LIST = [
|
||||
{ name: 'notFound', children: [] },
|
||||
{ name: 'login', children: [] },
|
||||
{ name: 'noPermission', children: [] },
|
||||
];
|
||||
|
||||
export const NOT_FOUND = {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('/')
|
||||
const requested = storedRedirect?.startsWith('/')
|
||||
? { path: storedRedirect }
|
||||
: { name: (redirect as string) || 'dashboard-overview', query: othersQuery }),
|
||||
});
|
||||
: 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<string, string> = {
|
||||
|
||||
78
frontend/platform_admin/src/views/no-permission/index.vue
Normal file
78
frontend/platform_admin/src/views/no-permission/index.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<!--
|
||||
功能:为未分配任何菜单的平台账号提供可恢复的权限引导页。
|
||||
版本:v1.0.0
|
||||
-->
|
||||
<template>
|
||||
<main class="permission-page">
|
||||
<a-result
|
||||
status="403"
|
||||
title="尚未分配菜单权限"
|
||||
subtitle="当前账号已登录,但还没有可访问的业务页面。请联系平台根管理员分配菜单权限。"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button :loading="reloading" type="primary" @click="reloadPermission">
|
||||
重新加载权限
|
||||
</a-button>
|
||||
<a-button @click="logout">退出登录</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-result>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { findFirstAuthorizedMenuRoute } from '@/router/authorized-landing';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
|
||||
defineOptions({ name: 'NoPermissionPage' });
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const reloading = ref(false);
|
||||
|
||||
/** 重新读取服务端角色菜单,授权生效后直接进入首个业务页。 */
|
||||
async function reloadPermission() {
|
||||
reloading.value = true;
|
||||
try {
|
||||
await userStore.info();
|
||||
const destination = findFirstAuthorizedMenuRoute(appStore.appAsyncMenus);
|
||||
if (!destination) {
|
||||
Message.warning('仍未分配菜单权限');
|
||||
return;
|
||||
}
|
||||
await router.replace(destination);
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
reloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出当前账号并返回登录页。 */
|
||||
async function logout() {
|
||||
await userStore.logout();
|
||||
await router.replace({ name: 'login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.permission-page {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: var(--color-fill-2);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.permission-page :deep(.arco-result) {
|
||||
width: min(640px, 100%);
|
||||
padding: 56px 32px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!--
|
||||
功能:以父子树方式分配平台角色菜单,仅向表单写入叶子菜单标识。
|
||||
版本:v1.0.0
|
||||
-->
|
||||
<template>
|
||||
<div class="menu-permission-tree">
|
||||
<a-input v-model="keyword" allow-clear placeholder="搜索菜单名称或完整路径" />
|
||||
<div v-if="visibleGroups.length" class="menu-groups">
|
||||
<section v-for="group in visibleGroups" :key="String(group.identity)" class="menu-group">
|
||||
<a-checkbox
|
||||
v-if="group.standalone"
|
||||
:model-value="selected.has(String(group.identity))"
|
||||
@change="(checked: unknown) => toggleLeaf(String(group.identity), checked === true)"
|
||||
>
|
||||
{{ group.name }}
|
||||
</a-checkbox>
|
||||
<a-checkbox
|
||||
v-else
|
||||
:model-value="groupChecked(group)"
|
||||
:indeterminate="groupIndeterminate(group)"
|
||||
@change="(checked: unknown) => toggleGroup(group, checked === true)"
|
||||
>
|
||||
{{ group.name }}
|
||||
</a-checkbox>
|
||||
<div v-if="!group.standalone" class="menu-children">
|
||||
<a-checkbox
|
||||
v-for="child in group.children"
|
||||
:key="String(child.identity)"
|
||||
:model-value="selected.has(String(child.identity))"
|
||||
@change="(checked: unknown) => toggleLeaf(String(child.identity), checked === true)"
|
||||
>
|
||||
{{ group.name }} / {{ child.name }}
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<a-empty v-else description="没有匹配的菜单" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ResourceRow } from '@/api/resource-page-rules';
|
||||
|
||||
type MenuGroup = ResourceRow & { children: ResourceRow[]; standalone: boolean };
|
||||
|
||||
const props = defineProps<{ options: ResourceRow[] }>();
|
||||
const model = defineModel<string[]>({ required: true });
|
||||
const keyword = ref('');
|
||||
const selected = computed(() => new Set((model.value ?? []).map(String)));
|
||||
|
||||
/** 将静态菜单扁平数据整理为两层展示树,父节点不进入提交值。 */
|
||||
const groups = computed<MenuGroup[]>(() => {
|
||||
const children = new Map<string, ResourceRow[]>();
|
||||
for (const item of props.options) {
|
||||
const parent = String(item.parent_identity ?? '');
|
||||
if (!parent) continue;
|
||||
children.set(parent, [...(children.get(parent) ?? []), item]);
|
||||
}
|
||||
return props.options
|
||||
.filter((item) => !String(item.parent_identity ?? ''))
|
||||
.map((item) => {
|
||||
const items = children.get(String(item.identity)) ?? [];
|
||||
return { ...item, children: items, standalone: items.length === 0 };
|
||||
});
|
||||
});
|
||||
|
||||
/** 搜索命中父级时保留全部子级,命中子级时保留其父级上下文。 */
|
||||
const visibleGroups = computed<MenuGroup[]>(() => {
|
||||
const query = keyword.value.trim().toLowerCase();
|
||||
if (!query) return groups.value;
|
||||
return groups.value.flatMap((group) => {
|
||||
const parentMatches = String(group.name ?? '')
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
const matchedChildren = parentMatches
|
||||
? group.children
|
||||
: group.children.filter((child) =>
|
||||
`${String(group.name ?? '')} / ${String(child.name ?? '')}`
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
);
|
||||
return matchedChildren.length
|
||||
? [{ ...group, children: matchedChildren }]
|
||||
: [];
|
||||
});
|
||||
});
|
||||
|
||||
function groupChecked(group: MenuGroup) {
|
||||
return (
|
||||
group.children.length > 0 &&
|
||||
group.children.every((child) => selected.value.has(String(child.identity)))
|
||||
);
|
||||
}
|
||||
|
||||
function groupIndeterminate(group: MenuGroup) {
|
||||
const count = group.children.filter((child) =>
|
||||
selected.value.has(String(child.identity)),
|
||||
).length;
|
||||
return count > 0 && count < group.children.length;
|
||||
}
|
||||
|
||||
/** 父级仅执行当前分组全部叶子的批量选择,不作为独立权限保存。 */
|
||||
function toggleGroup(group: MenuGroup, checked: boolean) {
|
||||
const next = new Set(selected.value);
|
||||
for (const child of group.children) {
|
||||
const identity = String(child.identity);
|
||||
checked ? next.add(identity) : next.delete(identity);
|
||||
}
|
||||
model.value = [...next];
|
||||
}
|
||||
|
||||
function toggleLeaf(identity: string, checked: boolean) {
|
||||
const next = new Set(selected.value);
|
||||
checked ? next.add(identity) : next.delete(identity);
|
||||
model.value = [...next];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.menu-permission-tree { display: grid; gap: 12px; }
|
||||
.menu-groups { max-height: 420px; overflow: auto; border: 1px solid var(--color-border-2); border-radius: 4px; }
|
||||
.menu-group { padding: 12px 16px; border-bottom: 1px solid var(--color-border-1); }
|
||||
.menu-group:last-child { border-bottom: 0; }
|
||||
.menu-children { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 20px; padding: 10px 0 0 24px; }
|
||||
@media (max-width: 700px) { .menu-children { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -40,7 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
@@ -62,6 +62,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
const form = reactive<Record<string, any>>({});
|
||||
const submitting = ref(false);
|
||||
const emptyMenuAssignmentConfirmed = ref(false);
|
||||
const relations = useResourceRelations();
|
||||
const isProductLifecycleAction = computed(
|
||||
() => props.action?.resource === '/product_info/:identity/lifecycle',
|
||||
@@ -232,10 +233,14 @@ watch(
|
||||
async ([visible]) => {
|
||||
if (!visible || !props.action) return;
|
||||
for (const field of actionFields.value) {
|
||||
form[field.key] = isOwnershipAction.value
|
||||
form[field.key] =
|
||||
field.type === 'menu-tree'
|
||||
? []
|
||||
: isOwnershipAction.value
|
||||
? props.record[field.key]
|
||||
: undefined;
|
||||
}
|
||||
emptyMenuAssignmentConfirmed.value = false;
|
||||
if (isGasorderAssignmentAction.value) {
|
||||
await initializeGasorderAssignment();
|
||||
return;
|
||||
@@ -264,6 +269,22 @@ function close() {
|
||||
async function submit() {
|
||||
const action = props.action;
|
||||
if (!action) return;
|
||||
if (
|
||||
action.resource.includes('/menu') &&
|
||||
!emptyMenuAssignmentConfirmed.value &&
|
||||
(!Array.isArray(form.menu_identities) || form.menu_identities.length === 0)
|
||||
) {
|
||||
Modal.warning({
|
||||
title: '确认清空菜单权限',
|
||||
content: '保存后,该角色下的账号将无法访问任何业务页面。是否继续?',
|
||||
hideCancel: false,
|
||||
onOk: () => {
|
||||
emptyMenuAssignmentConfirmed.value = true;
|
||||
void submit();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
actionFields.value.some(
|
||||
(field) => field.required && isMissingField(form[field.key]),
|
||||
|
||||
@@ -56,6 +56,11 @@
|
||||
:disabled="disabledSet.has(field.key)"
|
||||
:placeholder="requiredKeys.includes(field.key) ? '请输入至少 6 个字符' : '留空表示不修改密码'"
|
||||
/>
|
||||
<MenuPermissionTree
|
||||
v-else-if="field.type === 'menu-tree'"
|
||||
v-model="model[field.key]"
|
||||
:options="relationOptions[field.relation ?? ''] ?? []"
|
||||
/>
|
||||
<a-select
|
||||
v-else-if="field.key === 'platform_role_code'"
|
||||
v-model="model[field.key]"
|
||||
@@ -156,6 +161,7 @@ import IdentityText from '@/components/IdentityText.vue';
|
||||
import ContractAttachmentField, {
|
||||
type ContractAttachmentFieldState,
|
||||
} from './ContractAttachmentField.vue';
|
||||
import MenuPermissionTree from './MenuPermissionTree.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -199,6 +205,7 @@ function isWide(field: ResourceField) {
|
||||
return (
|
||||
field.type === 'textarea' ||
|
||||
field.type === 'identity-list' ||
|
||||
field.type === 'menu-tree' ||
|
||||
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user