完善平台角色菜单树与授权登录导航

This commit is contained in:
czl231
2026-08-17 21:56:22 +08:00
parent c7449cb38d
commit d6bb3bd43e
22 changed files with 739 additions and 17 deletions

View File

@@ -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()

View File

@@ -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") {

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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` 权限,不因拥有订单列表权限自动获得。

View 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 警告;修正后无新增控制台警告。
- 当前平台账户列表没有普通角色账号,因此未创建临时数据,也未执行普通账号真实登录;该链路由后端授权测试和前端登录落点检查覆盖。

View 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 定向检查退出码为 0Vue 模板引用仍有工具级未使用警告,本次新页面组件名已显式声明。
## 风险评估
- 不修改登录接口、令牌格式、角色菜单表或后端鉴权,公共接口保持兼容。
- 登录阶段增加一次已有资料请求,用于在跳转前取得真实菜单权限。

View 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`

View File

@@ -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",

View File

@@ -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');

View File

@@ -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('角色菜单树静态检查通过');

View File

@@ -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;
}

View File

@@ -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'),

View 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,
}
);
}

View File

@@ -1,6 +1,7 @@
export const WHITE_LIST = [
{ name: 'notFound', children: [] },
{ name: 'login', children: [] },
{ name: 'noPermission', children: [] },
];
export const NOT_FOUND = {

View File

@@ -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() {

View File

@@ -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',

View File

@@ -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> = {

View 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>

View File

@@ -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>

View File

@@ -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]),

View File

@@ -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)
);
}