From 160172bf10280ce4c49c926bf69e186db47d6570 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 12:48:35 +0800 Subject: [PATCH] fix: enforce platform role menu access --- backend/api/internal/logic/platform/access.go | 82 ++++++++++++++ backend/api/internal/logic/platform/auth.go | 11 +- .../api/internal/logic/platform/platform.go | 4 +- .../internal/logic/platform/resource_test.go | 106 ++++++++++++++++++ backend/api/internal/logic/platform/role.go | 64 +++++++++-- .../logic/platform/task4_resources.go | 70 ++++++++---- backend/api/internal/routers/platform.go | 3 + backend/api/internal/routers/platform_test.go | 1 + .../scripts/final-important.test.mjs | 29 +++++ frontend/platform_admin/src/api/auth.ts | 2 +- frontend/platform_admin/src/api/platform.ts | 12 ++ .../platform_admin/src/api/resource-form.ts | 4 +- frontend/platform_admin/src/api/resources.ts | 26 ++++- .../platform_admin/src/hooks/permission.ts | 19 ++-- .../src/router/routes/modules/audit.ts | 6 +- .../src/router/routes/modules/content.ts | 2 +- .../router/routes/modules/customer_service.ts | 2 +- .../src/router/routes/modules/delivery.ts | 10 +- .../src/router/routes/modules/device.ts | 6 +- .../src/router/routes/modules/ec.ts | 16 +-- .../src/router/routes/modules/finance.ts | 6 +- .../src/router/routes/modules/gas.ts | 4 +- .../src/router/routes/modules/notification.ts | 2 +- .../src/router/routes/modules/platform.ts | 8 +- .../src/router/routes/modules/report.ts | 6 +- .../src/router/routes/modules/safety.ts | 6 +- .../src/router/routes/modules/staff.ts | 4 +- .../src/router/routes/modules/user.ts | 6 +- .../src/router/routes/modules/wallet.ts | 8 +- .../platform_admin/src/router/typings.d.ts | 1 + .../src/store/modules/user/index.ts | 9 +- .../src/store/modules/user/types.ts | 3 +- .../src/views/shared/CrudListPage.vue | 50 ++++++++- 33 files changed, 481 insertions(+), 107 deletions(-) create mode 100644 backend/api/internal/logic/platform/access.go diff --git a/backend/api/internal/logic/platform/access.go b/backend/api/internal/logic/platform/access.go new file mode 100644 index 0000000..9b876e2 --- /dev/null +++ b/backend/api/internal/logic/platform/access.go @@ -0,0 +1,82 @@ +package platform + +import ( + "strings" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +const platformMenusContextKey = "platform_authorized_menus" + +func loadPlatformMenus(roleCode string) ([]models.PlatformMenu, error) { + var menus []models.PlatformMenu + if roleCode == "root" { + err := impl.DBService.Order("sort_no asc, id asc").Find(&menus).Error + return menus, err + } + + var role models.PlatformRole + if err := impl.DBService.Where("role_code = ? AND status = ?", roleCode, "enabled").First(&role).Error; err != nil { + return nil, err + } + err := impl.DBService. + Select("platform_menu.*"). + Joins("JOIN platform_role_menu_relation ON platform_role_menu_relation.platform_menu_id = platform_menu.id"). + Where("platform_role_menu_relation.platform_role_id = ? AND platform_menu.status = ?", role.ID, "enabled"). + Order("sort_no asc, id asc"). + Find(&menus).Error + return menus, err +} + +func platformMenuAllowsPath(menus []models.PlatformMenu, requestPath string) bool { + marker := "/platform/v1/" + index := strings.Index(requestPath, marker) + if index < 0 { + return false + } + relative := strings.Trim(requestPath[index+len(marker):], "/") + domain := strings.Split(relative, "/")[0] + for _, menu := range menus { + if menu.MenuCode == domain { + return true + } + menuPath := strings.Trim(menu.Path, "/") + if menuPath != "" && strings.Split(menuPath, "/")[0] == domain { + return true + } + } + return false +} + +// RequirePlatformMenuAccess enforces role-menu authorization after JWT authentication. +func RequirePlatformMenuAccess() gin.HandlerFunc { + return func(ctx *gin.Context) { + if strings.Contains(ctx.Request.URL.Path, "/platform/v1/auth/") { + ctx.Next() + return + } + claims, err := middleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, err) + ctx.Abort() + return + } + if claims.Role == "root" { + ctx.Next() + return + } + menus, err := loadPlatformMenus(claims.Role) + if err != nil || !platformMenuAllowsPath(menus, ctx.Request.URL.Path) { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + ctx.Abort() + return + } + ctx.Set(platformMenusContextKey, menus) + ctx.Next() + } +} diff --git a/backend/api/internal/logic/platform/auth.go b/backend/api/internal/logic/platform/auth.go index 3932c2f..75abda6 100644 --- a/backend/api/internal/logic/platform/auth.go +++ b/backend/api/internal/logic/platform/auth.go @@ -90,9 +90,18 @@ func CurrentProfile(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrRecordNotFound) return } + menus, err := loadPlatformMenus(account.PlatformRoleCode) + if err != nil { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return + } + menuCodes := make([]string, 0, len(menus)) + for _, menu := range menus { + menuCodes = append(menuCodes, menu.MenuCode) + } infra.Response.Success(ctx, gin.H{ "identity": account.Identity, "username": account.Username, "display_name": account.DisplayName, - "avatar": account.Avatar, "role_code": account.PlatformRoleCode, + "avatar": account.Avatar, "role_code": account.PlatformRoleCode, "menu_codes": menuCodes, }) } diff --git a/backend/api/internal/logic/platform/platform.go b/backend/api/internal/logic/platform/platform.go index 4d80900..b66084e 100644 --- a/backend/api/internal/logic/platform/platform.go +++ b/backend/api/internal/logic/platform/platform.go @@ -55,7 +55,7 @@ func listPage[T any](ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": total, "list": response}) + infra.Response.Success(ctx, gin.H{"total": total, "list": protectPreciseLocation(ctx, model, response)}) } var keywordSafeColumns = map[string]bool{ @@ -140,7 +140,7 @@ func getByIdentity[T any](ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, response) + infra.Response.Success(ctx, protectPreciseLocation(ctx, new(T), response)) } func updateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) { diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index f48eb24..b4c0200 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -346,6 +346,7 @@ func TestListPlatformMenuReturnsParentIdentityWithoutParentID(t *testing.T) { AddRow(uint64(2), "child-a", nil, nil, "enabled", 1, uint64(1), "child", "Child", "", "/child", 2)) ctx, recorder := updateContext(http.MethodGet, "/platform/platform_menu", "", nil) + ctx.Set("Auth", &types.JwtClaims{Role: "root"}) ListPlatformMenu(ctx) assertResponseCode(t, recorder, 0) @@ -355,6 +356,29 @@ func TestListPlatformMenuReturnsParentIdentityWithoutParentID(t *testing.T) { assertMockExpectations(t, mock) } +func TestListPlatformMenuReturnsOnlyMenusAssignedToNonRootRole(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE role_code = $1 AND status = $2 ORDER BY "platform_role"."id" LIMIT $3`)). + WithArgs("finance_operator", "enabled", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "role_code", "name", "data_scope", "is_system"}). + AddRow(uint64(7), "finance-role", nil, nil, "enabled", 1, "finance_operator", "Finance", "global", false)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT platform_menu.* FROM "platform_menu" JOIN platform_role_menu_relation ON platform_role_menu_relation.platform_menu_id = platform_menu.id WHERE platform_role_menu_relation.platform_role_id = $1 AND platform_menu.status = $2 ORDER BY sort_no asc, id asc`)). + WithArgs(uint64(7), "enabled"). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "parent_id", "menu_code", "name", "icon", "path", "sort_no"}). + AddRow(uint64(9), "finance-menu", nil, nil, "enabled", 1, uint64(0), "finance", "Finance", "", "/finance/payment", 1)) + + ctx, recorder := updateContext(http.MethodGet, "/platform/platform_menu", "", nil) + ctx.Set("Auth", &types.JwtClaims{Role: "finance_operator"}) + ListPlatformMenu(ctx) + + assertResponseCode(t, recorder, 0) + body := recorder.Body.String() + if !strings.Contains(body, `"identity":"finance-menu"`) || strings.Contains(body, `"gas-menu"`) { + t.Fatalf("non-root menu response was not constrained: %s", body) + } + assertMockExpectations(t, mock) +} + func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) { response := resourceResponse(map[string]any{"identity": "item-a", "id": uint64(1), "ec_order_id": uint64(2), "ec_product_id": uint64(3), "items": []any{map[string]any{"identity": "child-a", "delivery_task_id": uint64(4)}}}) encoded, err := json.Marshal(response) @@ -398,6 +422,88 @@ func TestCreatedResourceResponseUsesSafeAllowlist(t *testing.T) { } } +func TestDefaultResourceResponseMasksPIIAndCoordinates(t *testing.T) { + ctx, _ := updateContext(http.MethodGet, "/user/account/user-a", "user-a", nil) + response := protectPreciseLocation(ctx, &models.UserAccount{}, map[string]any{ + "identity": "user-a", + "name": "张三", + "phone": "13800138000", + "avatar": "https://private.example/avatar.png", + "address": "敏感详细地址", + "longitude": "120.123456", + "latitude": "30.456789", + }) + encoded, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + body := string(encoded) + if !strings.Contains(body, `"identity":"user-a"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) || !strings.Contains(body, `"name_masked"`) { + t.Fatalf("default response omitted safe identity or masked PII: %s", body) + } + for _, forbidden := range []string{`"phone":`, `"name":`, `"avatar":`, `"address":`, `"longitude":"120.123456"`, `"latitude":"30.456789"`, "张三", "敏感详细地址", "private.example"} { + if strings.Contains(body, forbidden) { + t.Fatalf("default response leaked %s: %s", forbidden, body) + } + } +} + +func TestExplicitPreciseScopeRetainsCoordinatesButStillMasksPII(t *testing.T) { + ctx, _ := updateContext(http.MethodGet, "/user/address/address-a", "address-a", nil) + ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}}) + response := protectPreciseLocation(ctx, &models.UserAddress{}, map[string]any{ + "identity": "address-a", + "address": "敏感详细地址", + "longitude": "120.123456", + "latitude": "30.456789", + }) + encoded, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + body := string(encoded) + if !strings.Contains(body, `"longitude":"120.123456"`) || !strings.Contains(body, `"latitude":"30.456789"`) { + t.Fatalf("authorized response omitted precise coordinates: %s", body) + } + if strings.Contains(body, `"address":`) || strings.Contains(body, "敏感详细地址") { + t.Fatalf("precise location scope leaked address PII: %s", body) + } +} + +func TestPlatformMenuAllowsOnlyAssignedDomain(t *testing.T) { + menus := []models.PlatformMenu{ + {MenuCode: "finance", Path: "/finance"}, + {MenuCode: "fin_payment", Path: "/finance/fin-payment"}, + } + if !platformMenuAllowsPath(menus, "/heqi/platform/v1/finance/fin_payment") { + t.Fatal("assigned finance domain should be allowed") + } + if platformMenuAllowsPath(menus, "/heqi/platform/v1/user/account") { + t.Fatal("unassigned user domain should be denied") + } +} + +func TestCreatePlatformAccountRequiresAssignableNonRootRole(t *testing.T) { + tests := []struct { + name string + body string + }{ + {"missing role", `{"username":"operator","password":"secure-password"}`}, + {"root role", `{"username":"operator","password":"secure-password","platform_role_code":"root"}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + ctx, recorder := updateContext(http.MethodPost, "/platform/platfrom_account", "", []byte(test.body)) + + CreatePlatfromAccount(ctx) + + assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument))) + assertMockExpectations(t, mock) + }) + } +} + func TestListSafetyEventDisposalsReturnsOnlyTheRequestedEventHistory(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "saf_event_disposal" WHERE saf_event_identity = $1`)). diff --git a/backend/api/internal/logic/platform/role.go b/backend/api/internal/logic/platform/role.go index 33f078e..e11f5e9 100644 --- a/backend/api/internal/logic/platform/role.go +++ b/backend/api/internal/logic/platform/role.go @@ -5,6 +5,7 @@ import ( "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/bsm-sdk/core/middleware" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" @@ -195,6 +196,25 @@ func ReplacePlatformRoleMenus(ctx *gin.Context) { infra.Response.Success(ctx, gin.H{"updated": true}) } +// ListPlatformRoleMenuIdentities returns the current assignment for the role editor. +func ListPlatformRoleMenuIdentities(ctx *gin.Context) { + var role models.PlatformRole + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil { + respondRecordError(ctx, err) + return + } + var identities []string + if err := impl.DBService.Model(&models.PlatformMenu{}). + Joins("JOIN platform_role_menu_relation ON platform_role_menu_relation.platform_menu_id = platform_menu.id"). + Where("platform_role_menu_relation.platform_role_id = ?", role.ID). + Order("platform_menu.sort_no asc, platform_menu.id asc"). + Pluck("platform_menu.identity", &identities).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"menu_identities": identities}) +} + // UpdatePlatformRoleStatus 更新非内置平台角色状态,系统角色始终受保护。 func UpdatePlatformRoleStatus(ctx *gin.Context) { var request struct { @@ -232,8 +252,13 @@ func ArchivePlatformRole(ctx *gin.Context) { // ListPlatformMenu 返回菜单树构建所需的有序菜单列表。 func ListPlatformMenu(ctx *gin.Context) { - var list []models.PlatformMenu - if err := impl.DBService.Order("sort_no asc, id asc").Find(&list).Error; err != nil { + claims, err := middleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, err) + return + } + list, err := loadPlatformMenus(claims.Role) + if err != nil { infra.Response.Error(ctx, err) return } @@ -266,7 +291,7 @@ type platfromAccountRequest struct { Password string `json:"password" binding:"required,min=8,max=128"` DisplayName string `json:"display_name" binding:"max=64"` Avatar string `json:"avatar" binding:"max=512"` - PlatformRoleCode string `json:"platform_role_code" binding:"max=64"` + PlatformRoleCode string `json:"platform_role_code" binding:"required,max=64"` Phone string `json:"phone" binding:"max=32"` } @@ -299,15 +324,16 @@ func CreatePlatfromAccount(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + if !isAssignablePlatformRole(request.PlatformRoleCode) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } hash, err := passwordHash(request.Password) if err != nil { infra.Response.Error(ctx, err) return } account := models.PlatfromAccount{Entity: newEntity("enabled"), Username: request.Username, DisplayName: request.DisplayName, Avatar: request.Avatar, PasswordHash: hash, PlatformRoleCode: request.PlatformRoleCode, Phone: request.Phone} - if account.PlatformRoleCode == "" { - account.PlatformRoleCode = "root" - } if err := impl.DBService.Create(&account).Error; err != nil { infra.Response.Error(ctx, err) return @@ -317,14 +343,30 @@ func CreatePlatfromAccount(ctx *gin.Context) { func UpdatePlatfromAccount(ctx *gin.Context) { var request struct { - DisplayName string `json:"display_name" binding:"max=64"` - Avatar string `json:"avatar" binding:"max=512"` - PlatformRoleCode string `json:"platform_role_code" binding:"max=64"` - Phone string `json:"phone" binding:"max=32"` + DisplayName string `json:"display_name" binding:"max=64"` + Avatar string `json:"avatar" binding:"max=512"` + PlatformRoleCode *string `json:"platform_role_code" binding:"omitempty,max=64"` + Phone string `json:"phone" binding:"max=32"` } if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.PlatfromAccount{}, gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "platform_role_code": request.PlatformRoleCode, "phone": request.Phone}, []string{"display_name", "avatar", "platform_role_code", "phone"}) + values := gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "phone": request.Phone} + if request.PlatformRoleCode != nil { + if !isAssignablePlatformRole(*request.PlatformRoleCode) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + values["platform_role_code"] = *request.PlatformRoleCode + } + updateAllowedByIdentity(ctx, &models.PlatfromAccount{}, values, []string{"display_name", "avatar", "platform_role_code", "phone"}) +} + +func isAssignablePlatformRole(roleCode string) bool { + if roleCode == "" || roleCode == "root" { + return false + } + var role models.PlatformRole + return impl.DBService.Where("role_code = ? AND status = ? AND is_system = ?", roleCode, "enabled", false).First(&role).Error == nil } diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index a5468f5..b7e7777 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -188,37 +188,61 @@ func isCreatedResponseField(key string) bool { } func protectPreciseLocation(ctx *gin.Context, model, value any) any { - if reflect.TypeOf(model) != reflect.TypeOf(&models.DeliveryTrackPoint{}) || hasPreciseLocationScope(ctx) { - return value - } - clearCoordinateFields(value) + maskPersonalName := reflect.TypeOf(model) == reflect.TypeOf(&models.UserAccount{}) || + reflect.TypeOf(model) == reflect.TypeOf(&models.StaffAccount{}) + protectPublicFields(value, maskPersonalName, hasPreciseLocationScope(ctx)) return value } +func protectPublicFields(value any, maskPersonalName, retainCoordinates bool) { + switch data := value.(type) { + case map[string]any: + if phone, ok := data["phone"].(string); ok && phone != "" { + data["phone_masked"] = maskPhone(phone) + } + delete(data, "phone") + delete(data, "avatar") + delete(data, "address") + if maskPersonalName { + if name, ok := data["name"].(string); ok && name != "" { + data["name_masked"] = maskPersonalNameValue(name) + } + delete(data, "name") + if name, ok := data["real_name"].(string); ok && name != "" { + data["real_name_masked"] = maskPersonalNameValue(name) + } + delete(data, "real_name") + } + if !retainCoordinates { + delete(data, "longitude") + delete(data, "latitude") + } + for _, item := range data { + protectPublicFields(item, maskPersonalName, retainCoordinates) + } + case []any: + for _, item := range data { + protectPublicFields(item, maskPersonalName, retainCoordinates) + } + } +} + +func maskPersonalNameValue(name string) string { + runes := []rune(name) + if len(runes) == 0 { + return "" + } + if len(runes) == 1 { + return "*" + } + return string(runes[0]) + strings.Repeat("*", len(runes)-1) +} + func hasPreciseLocationScope(ctx *gin.Context) bool { claims, err := middleware.ParseAuth(ctx) return err == nil && claims.Extend["location_scope"] == "precise" } -func clearCoordinateFields(value any) { - switch data := value.(type) { - case map[string]any: - if _, ok := data["longitude"]; ok { - data["longitude"] = "" - } - if _, ok := data["latitude"]; ok { - data["latitude"] = "" - } - for _, item := range data { - clearCoordinateFields(item) - } - case []any: - for _, item := range data { - clearCoordinateFields(item) - } - } -} - func updateResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) { var input map[string]any if err := ctx.ShouldBindJSON(&input); err != nil { diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 6cb064a..fc30a1a 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -18,6 +18,7 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { protected := engine.Group(basePath) protected.Use(middleware.JwtAuth(true)) + protected.Use(platform.RequirePlatformMenuAccess()) protected.GET("/auth/profile", platform.CurrentProfile) protected.PUT("/auth/password", platform.ChangePassword) protected.GET("/dashboard/overview", platform.DashboardOverview) @@ -103,6 +104,8 @@ func registerPlatformRoute(group *gin.RouterGroup) { role.PUT("/:identity", platform.UpdatePlatformRole) role.PATCH("/:identity/status", platform.UpdatePlatformRoleStatus) role.DELETE("/:identity", platform.ArchivePlatformRole) + role.GET("/:identity/menu", platform.ListPlatformRoleMenuIdentities) + role.PUT("/:identity/menu", platform.ReplacePlatformRoleMenus) role.PUT("/:identity/menus", platform.ReplacePlatformRoleMenus) registerWritableResource(group, "/platform/platform_menu", platform.ListPlatformMenu, platform.CreatePlatformMenu, platform.GetPlatformMenu, platform.UpdatePlatformMenu, &models.PlatformMenu{}) } diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 3a64e57..078edf4 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -83,6 +83,7 @@ func TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD(t *testing.T) { assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity/status", http.MethodPatch) } + assertRouteMethods(t, routes, "/heqi/platform/v1/platform/platform_role/:identity/menu", http.MethodGet, http.MethodPut) assertRouteMethods(t, routes, "/heqi/platform/v1/platform/platform_role/:identity/menus", http.MethodPut) } diff --git a/frontend/platform_admin/scripts/final-important.test.mjs b/frontend/platform_admin/scripts/final-important.test.mjs index d96b5f0..10a4239 100644 --- a/frontend/platform_admin/scripts/final-important.test.mjs +++ b/frontend/platform_admin/scripts/final-important.test.mjs @@ -143,3 +143,32 @@ test('树页面通过资源归档接口归档节点', () => { assert.match(source, /resourceApi\.archive/); assert.match(source, /Modal\.(warning|confirm)/); }); + +test('route access uses authenticated role and assigned menu codes', () => { + const routeDir = fromProjectRoot('src/router/routes/modules'); + const routeSource = fs.readdirSync(routeDir) + .filter((name) => name.endsWith('.ts')) + .map((name) => fs.readFileSync(path.join(routeDir, name), 'utf8')) + .join('\n'); + const userStore = fs.readFileSync(fromProjectRoot('src/store/modules/user/index.ts'), 'utf8'); + const permission = fs.readFileSync(fromProjectRoot('src/hooks/permission.ts'), 'utf8'); + + assert.doesNotMatch(routeSource, /roles:\s*\[\s*['"]\*['"]\s*\]/); + assert.match(routeSource, /menuCode:\s*['"]finance['"]/); + assert.match(userStore, /profile\.role_code/); + assert.match(userStore, /menuCodes/); + assert.match(permission, /menuCode/); +}); + +test('platform role UI replaces assigned menu identities through the singular contract URL', () => { + const resources = fs.readFileSync(fromProjectRoot('src/api/resources.ts'), 'utf8'); + const crudPage = fs.readFileSync(fromProjectRoot('src/views/shared/CrudListPage.vue'), 'utf8'); + const platformApi = fs.readFileSync(fromProjectRoot('src/api/platform.ts'), 'utf8'); + + assert.match(resources, /platform_role[\s\S]*\/platform\/platform_role\/:identity\/menu/); + assert.match(resources, /menu-identities/); + assert.match(crudPage, /multiple/); + assert.match(crudPage, /menu_identities/); + assert.match(platformApi, /\/platform\/platform_role\/\$\{identity\}\/menu/); + assert.match(platformApi, /method:\s*['"]PUT['"]/); +}); diff --git a/frontend/platform_admin/src/api/auth.ts b/frontend/platform_admin/src/api/auth.ts index 5609540..6ad8e23 100644 --- a/frontend/platform_admin/src/api/auth.ts +++ b/frontend/platform_admin/src/api/auth.ts @@ -3,7 +3,7 @@ import { request } from './http'; /** 平台登录和当前账号资料的接口模型。 */ export type LoginData = { username: string; password: string }; export type LoginReply = { access_token: string; token_type: string; identity: string; display_name: string; role_code: string }; -export type Profile = { identity: string; username: string; display_name: string; avatar: string; role_code: string }; +export type Profile = { identity: string; username: string; display_name: string; avatar: string; role_code: string; menu_codes: string[] }; export const authApi = { login: (data: LoginData) => request('/auth/login', { method: 'POST', body: JSON.stringify(data) }), diff --git a/frontend/platform_admin/src/api/platform.ts b/frontend/platform_admin/src/api/platform.ts index c156d50..2473a15 100644 --- a/frontend/platform_admin/src/api/platform.ts +++ b/frontend/platform_admin/src/api/platform.ts @@ -11,4 +11,16 @@ export const platformApi = { listRole: () => resourceApi.list('/platform/platform_role'), createRole: (data: Record) => resourceApi.create('/platform/platform_role', data), listMenu: () => request<{ total: number; list: PlatformMenu[] }>('/platform/platform_menu'), + listRoleMenuIdentities: (identity: string) => + request<{ menu_identities: string[] }>( + `/platform/platform_role/${identity}/menu`, + ), + replaceRoleMenus: (identity: string, menuIdentities: string[]) => + request<{ updated: boolean }>( + `/platform/platform_role/${identity}/menu`, + { + method: 'PUT', + body: JSON.stringify({ menu_identities: menuIdentities }), + }, + ), }; diff --git a/frontend/platform_admin/src/api/resource-form.ts b/frontend/platform_admin/src/api/resource-form.ts index e69307f..b7eda88 100644 --- a/frontend/platform_admin/src/api/resource-form.ts +++ b/frontend/platform_admin/src/api/resource-form.ts @@ -1,10 +1,10 @@ import type { ResourceField } from './resources'; -export type ResourceFormValue = string | number | boolean | undefined; +export type ResourceFormValue = string | number | boolean | string[] | undefined; export type ResourceFormMode = 'create' | 'edit'; export function isMissingField(value: ResourceFormValue | null): boolean { - return value === '' || value === null || value === undefined; + return value === '' || value === null || value === undefined || (Array.isArray(value) && value.length === 0); } export function isResourceFieldRequired( diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index e4c2fac..1e4c189 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -4,6 +4,8 @@ export type ResourceFieldType = | 'text' | 'password' | 'identity' + | 'role-code' + | 'menu-identities' | 'number' | 'boolean' | 'date' @@ -105,6 +107,7 @@ const labels: Record = { category: '分类', priority: '优先级', platform_role_code: '平台角色', + menu_identities: '菜单权限', data_scope: '数据范围', menu_code: '菜单编码', icon: '图标', @@ -181,6 +184,8 @@ const jsonFields = new Set([ ]); const textareaFields = new Set(['body', 'content', 'reason', 'opinion']); const fieldType = (key: string): ResourceFieldType => { + if (key === 'platform_role_code') return 'role-code'; + if (key === 'menu_identities') return 'menu-identities'; if (key.endsWith('_identity')) return 'identity'; if (key === 'password') return 'password'; if (numberFields.has(key)) return 'number'; @@ -541,14 +546,23 @@ export const resources: ResourceUiDefinition[] = [ 'password!', 'display_name', 'avatar', - 'platform_role_code', + 'platform_role_code!', 'phone', ]), - define('platform_role', '/platform/platform_role', 'writable', 'list', [ - 'role_code!', - 'name!', - 'data_scope!', - ]), + define( + 'platform_role', + '/platform/platform_role', + 'writable', + 'list', + ['role_code!', 'name!', 'data_scope!'], + [ + action( + '分配菜单', + '/platform/platform_role/:identity/menu', + ['menu_identities!'], + ), + ], + ), define('platform_menu', '/platform/platform_menu', 'writable', 'tree', [ 'parent_identity', 'menu_code!', diff --git a/frontend/platform_admin/src/hooks/permission.ts b/frontend/platform_admin/src/hooks/permission.ts index 8e27b08..4276c30 100644 --- a/frontend/platform_admin/src/hooks/permission.ts +++ b/frontend/platform_admin/src/hooks/permission.ts @@ -7,19 +7,24 @@ export default function usePermission() { accessRouter(route: RouteLocationNormalized | RouteRecordRaw) { return ( !route.meta?.requiresAuth || - !route.meta?.roles || - route.meta?.roles?.includes('*') || - route.meta?.roles?.includes(userStore.role) + userStore.role === 'root' || + (!route.meta?.menuCode && !route.meta?.roles) || + Boolean( + route.meta?.menuCode && + userStore.menuCodes.includes(route.meta.menuCode), + ) || + Boolean(route.meta?.roles?.includes(userStore.role)) ); }, - findFirstPermissionRoute(_routers: any, role = 'admin') { + findFirstPermissionRoute(_routers: any, role = userStore.role) { const cloneRouters = [..._routers]; while (cloneRouters.length) { const firstElement = cloneRouters.shift(); if ( - firstElement?.meta?.roles?.find((el: string[]) => { - return el.includes('*') || el.includes(role); - }) + role === 'root' || + (firstElement?.meta?.menuCode && + userStore.menuCodes.includes(firstElement.meta.menuCode)) || + firstElement?.meta?.roles?.includes(role) ) return { name: firstElement.name }; if (firstElement?.children) { diff --git a/frontend/platform_admin/src/router/routes/modules/audit.ts b/frontend/platform_admin/src/router/routes/modules/audit.ts index 70f6d66..6a3232c 100644 --- a/frontend/platform_admin/src/router/routes/modules/audit.ts +++ b/frontend/platform_admin/src/router/routes/modules/audit.ts @@ -4,9 +4,9 @@ const routes: AppRouteRecordRaw[] = [{ path: '/audit', name: 'audit', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.audit', requiresAuth: true, icon: 'icon-apps', order: 22 }, children: [ - { path: 'aud-operation-log', name: 'audit-aud-operation-log', component: () => import('@/views/audit/aud_operation_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_operation_log', requiresAuth: true, roles: ['*'] } }, - { path: 'aud-export-log', name: 'audit-aud-export-log', component: () => import('@/views/audit/aud_export_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_export_log', requiresAuth: true, roles: ['*'] } }, - { path: 'aud-approval', name: 'audit-aud-approval', component: () => import('@/views/audit/aud_approval/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_approval', requiresAuth: true, roles: ['*'] } } + { path: 'aud-operation-log', name: 'audit-aud-operation-log', component: () => import('@/views/audit/aud_operation_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_operation_log', requiresAuth: true, menuCode: 'audit' } }, + { path: 'aud-export-log', name: 'audit-aud-export-log', component: () => import('@/views/audit/aud_export_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_export_log', requiresAuth: true, menuCode: 'audit' } }, + { path: 'aud-approval', name: 'audit-aud-approval', component: () => import('@/views/audit/aud_approval/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_approval', requiresAuth: true, menuCode: 'audit' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/content.ts b/frontend/platform_admin/src/router/routes/modules/content.ts index dc97fa6..d002bce 100644 --- a/frontend/platform_admin/src/router/routes/modules/content.ts +++ b/frontend/platform_admin/src/router/routes/modules/content.ts @@ -4,7 +4,7 @@ const routes: AppRouteRecordRaw[] = [{ path: '/content', name: 'content', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.content', requiresAuth: true, icon: 'icon-apps', order: 17 }, children: [ - { path: 'cnt-content', name: 'content-cnt-content', component: () => import('@/views/content/cnt_content/ListPage.vue'), meta: { locale: 'menu.platform.content.cnt_content', requiresAuth: true, roles: ['*'] } } + { path: 'cnt-content', name: 'content-cnt-content', component: () => import('@/views/content/cnt_content/ListPage.vue'), meta: { locale: 'menu.platform.content.cnt_content', requiresAuth: true, menuCode: 'content' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/customer_service.ts b/frontend/platform_admin/src/router/routes/modules/customer_service.ts index dde2a0d..5423e12 100644 --- a/frontend/platform_admin/src/router/routes/modules/customer_service.ts +++ b/frontend/platform_admin/src/router/routes/modules/customer_service.ts @@ -4,7 +4,7 @@ const routes: AppRouteRecordRaw[] = [{ path: '/customer_service', name: 'customer_service', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.customer_service', requiresAuth: true, icon: 'icon-apps', order: 19 }, children: [ - { path: 'cs-ticket', name: 'customer_service-cs-ticket', component: () => import('@/views/customer_service/cs_ticket/ListPage.vue'), meta: { locale: 'menu.platform.customer_service.cs_ticket', requiresAuth: true, roles: ['*'] } } + { path: 'cs-ticket', name: 'customer_service-cs-ticket', component: () => import('@/views/customer_service/cs_ticket/ListPage.vue'), meta: { locale: 'menu.platform.customer_service.cs_ticket', requiresAuth: true, menuCode: 'customer_service' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/delivery.ts b/frontend/platform_admin/src/router/routes/modules/delivery.ts index 5711b73..84a882b 100644 --- a/frontend/platform_admin/src/router/routes/modules/delivery.ts +++ b/frontend/platform_admin/src/router/routes/modules/delivery.ts @@ -4,11 +4,11 @@ const routes: AppRouteRecordRaw[] = [{ path: '/delivery', name: 'delivery', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.delivery', requiresAuth: true, icon: 'icon-apps', order: 11 }, children: [ - { path: 'delivery-basic', name: 'delivery-delivery-basic', component: () => import('@/views/delivery/delivery_basic/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_basic', requiresAuth: true, roles: ['*'] } }, - { path: 'delivery-account', name: 'delivery-delivery-account', component: () => import('@/views/delivery/delivery_account/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_account', requiresAuth: true, roles: ['*'] } }, - { path: 'delivery-task', name: 'delivery-delivery-task', component: () => import('@/views/delivery/delivery_task/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_task', requiresAuth: true, roles: ['*'] } }, - { path: 'delivery-track', name: 'delivery-delivery-track', component: () => import('@/views/delivery/delivery_track/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_track', requiresAuth: true, roles: ['*'] } }, - { path: 'delivery-track-point', name: 'delivery-delivery-track-point', component: () => import('@/views/delivery/delivery_track_point/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_track_point', requiresAuth: true, roles: ['*'] } } + { path: 'delivery-basic', name: 'delivery-delivery-basic', component: () => import('@/views/delivery/delivery_basic/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_basic', requiresAuth: true, menuCode: 'delivery' } }, + { path: 'delivery-account', name: 'delivery-delivery-account', component: () => import('@/views/delivery/delivery_account/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_account', requiresAuth: true, menuCode: 'delivery' } }, + { path: 'delivery-task', name: 'delivery-delivery-task', component: () => import('@/views/delivery/delivery_task/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_task', requiresAuth: true, menuCode: 'delivery' } }, + { path: 'delivery-track', name: 'delivery-delivery-track', component: () => import('@/views/delivery/delivery_track/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_track', requiresAuth: true, menuCode: 'delivery' } }, + { path: 'delivery-track-point', name: 'delivery-delivery-track-point', component: () => import('@/views/delivery/delivery_track_point/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_track_point', requiresAuth: true, menuCode: 'delivery' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/device.ts b/frontend/platform_admin/src/router/routes/modules/device.ts index ec5f922..d5f7c49 100644 --- a/frontend/platform_admin/src/router/routes/modules/device.ts +++ b/frontend/platform_admin/src/router/routes/modules/device.ts @@ -4,9 +4,9 @@ const routes: AppRouteRecordRaw[] = [{ path: '/device', name: 'device', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.device', requiresAuth: true, icon: 'icon-apps', order: 14 }, children: [ - { path: 'dev-smart-cylinder-valve', name: 'device-dev-smart-cylinder-valve', component: () => import('@/views/device/dev_smart_cylinder_valve/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_smart_cylinder_valve', requiresAuth: true, roles: ['*'] } }, - { path: 'dev-device-binding', name: 'device-dev-device-binding', component: () => import('@/views/device/dev_device_binding/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_device_binding', requiresAuth: true, roles: ['*'] } }, - { path: 'dev-telemetry', name: 'device-dev-telemetry', component: () => import('@/views/device/dev_telemetry/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_telemetry', requiresAuth: true, roles: ['*'] } } + { path: 'dev-smart-cylinder-valve', name: 'device-dev-smart-cylinder-valve', component: () => import('@/views/device/dev_smart_cylinder_valve/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_smart_cylinder_valve', requiresAuth: true, menuCode: 'device' } }, + { path: 'dev-device-binding', name: 'device-dev-device-binding', component: () => import('@/views/device/dev_device_binding/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_device_binding', requiresAuth: true, menuCode: 'device' } }, + { path: 'dev-telemetry', name: 'device-dev-telemetry', component: () => import('@/views/device/dev_telemetry/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_telemetry', requiresAuth: true, menuCode: 'device' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/ec.ts b/frontend/platform_admin/src/router/routes/modules/ec.ts index 917cc35..6d75423 100644 --- a/frontend/platform_admin/src/router/routes/modules/ec.ts +++ b/frontend/platform_admin/src/router/routes/modules/ec.ts @@ -1,13 +1,13 @@ import { DEFAULT_LAYOUT } from '../base'; import type { AppRouteRecordRaw } from '../types'; const routes: AppRouteRecordRaw[] = [{ path: '/ec', name: 'ec', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.ec', requiresAuth: true, icon: 'icon-apps', order: 16 }, children: [ - { path: 'ec-category', name: 'ec-ec-category', component: () => import('@/views/ec/ec_category/TreePage.vue'), meta: { locale: 'menu.platform.ec.ec_category', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-product', name: 'ec-ec-product', component: () => import('@/views/ec/ec_product/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-product-attribute', name: 'ec-ec-product-attribute', component: () => import('@/views/ec/ec_product_attribute/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_attribute', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-product-image', name: 'ec-ec-product-image', component: () => import('@/views/ec/ec_product_image/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_image', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-cart', name: 'ec-ec-cart', component: () => import('@/views/ec/ec_cart/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_cart', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-order', name: 'ec-ec-order', component: () => import('@/views/ec/ec_order/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_order', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-order-item', name: 'ec-ec-order-item', component: () => import('@/views/ec/ec_order_item/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_order_item', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-review', name: 'ec-ec-review', component: () => import('@/views/ec/ec_review/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_review', requiresAuth: true, roles: ['*'] } } + { path: 'ec-category', name: 'ec-ec-category', component: () => import('@/views/ec/ec_category/TreePage.vue'), meta: { locale: 'menu.platform.ec.ec_category', requiresAuth: true, menuCode: 'ec' } }, + { path: 'ec-product', name: 'ec-ec-product', component: () => import('@/views/ec/ec_product/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product', requiresAuth: true, menuCode: 'ec' } }, + { path: 'ec-product-attribute', name: 'ec-ec-product-attribute', component: () => import('@/views/ec/ec_product_attribute/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_attribute', requiresAuth: true, menuCode: 'ec' } }, + { path: 'ec-product-image', name: 'ec-ec-product-image', component: () => import('@/views/ec/ec_product_image/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_image', requiresAuth: true, menuCode: 'ec' } }, + { path: 'ec-cart', name: 'ec-ec-cart', component: () => import('@/views/ec/ec_cart/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_cart', requiresAuth: true, menuCode: 'ec' } }, + { path: 'ec-order', name: 'ec-ec-order', component: () => import('@/views/ec/ec_order/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_order', requiresAuth: true, menuCode: 'ec' } }, + { path: 'ec-order-item', name: 'ec-ec-order-item', component: () => import('@/views/ec/ec_order_item/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_order_item', requiresAuth: true, menuCode: 'ec' } }, + { path: 'ec-review', name: 'ec-ec-review', component: () => import('@/views/ec/ec_review/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_review', requiresAuth: true, menuCode: 'ec' } } ] }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/finance.ts b/frontend/platform_admin/src/router/routes/modules/finance.ts index 18d6db9..59c1a66 100644 --- a/frontend/platform_admin/src/router/routes/modules/finance.ts +++ b/frontend/platform_admin/src/router/routes/modules/finance.ts @@ -4,9 +4,9 @@ const routes: AppRouteRecordRaw[] = [{ path: '/finance', name: 'finance', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.finance', requiresAuth: true, icon: 'icon-apps', order: 16 }, children: [ - { path: 'fin-payment', name: 'finance-fin-payment', component: () => import('@/views/finance/fin_payment/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_payment', requiresAuth: true, roles: ['*'] } }, - { path: 'fin-settlement', name: 'finance-fin-settlement', component: () => import('@/views/finance/fin_settlement/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_settlement', requiresAuth: true, roles: ['*'] } }, - { path: 'fin-reconciliation', name: 'finance-fin-reconciliation', component: () => import('@/views/finance/fin_reconciliation/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_reconciliation', requiresAuth: true, roles: ['*'] } } + { path: 'fin-payment', name: 'finance-fin-payment', component: () => import('@/views/finance/fin_payment/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_payment', requiresAuth: true, menuCode: 'finance' } }, + { path: 'fin-settlement', name: 'finance-fin-settlement', component: () => import('@/views/finance/fin_settlement/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_settlement', requiresAuth: true, menuCode: 'finance' } }, + { path: 'fin-reconciliation', name: 'finance-fin-reconciliation', component: () => import('@/views/finance/fin_reconciliation/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_reconciliation', requiresAuth: true, menuCode: 'finance' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/gas.ts b/frontend/platform_admin/src/router/routes/modules/gas.ts index a314ce7..50518ae 100644 --- a/frontend/platform_admin/src/router/routes/modules/gas.ts +++ b/frontend/platform_admin/src/router/routes/modules/gas.ts @@ -4,8 +4,8 @@ const routes: AppRouteRecordRaw[] = [{ path: '/gas', name: 'gas', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.gas', requiresAuth: true, icon: 'icon-apps', order: 10 }, children: [ - { path: 'gas-basic', name: 'gas-gas-basic', component: () => import('@/views/gas/gas_basic/ListPage.vue'), meta: { locale: 'menu.platform.gas.gas_basic', requiresAuth: true, roles: ['*'] } }, - { path: 'gas-account', name: 'gas-gas-account', component: () => import('@/views/gas/gas_account/ListPage.vue'), meta: { locale: 'menu.platform.gas.gas_account', requiresAuth: true, roles: ['*'] } } + { path: 'gas-basic', name: 'gas-gas-basic', component: () => import('@/views/gas/gas_basic/ListPage.vue'), meta: { locale: 'menu.platform.gas.gas_basic', requiresAuth: true, menuCode: 'gas' } }, + { path: 'gas-account', name: 'gas-gas-account', component: () => import('@/views/gas/gas_account/ListPage.vue'), meta: { locale: 'menu.platform.gas.gas_account', requiresAuth: true, menuCode: 'gas' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/notification.ts b/frontend/platform_admin/src/router/routes/modules/notification.ts index e60b8cb..4847892 100644 --- a/frontend/platform_admin/src/router/routes/modules/notification.ts +++ b/frontend/platform_admin/src/router/routes/modules/notification.ts @@ -4,7 +4,7 @@ const routes: AppRouteRecordRaw[] = [{ path: '/notification', name: 'notification', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.notification', requiresAuth: true, icon: 'icon-apps', order: 18 }, children: [ - { path: 'ntf-template', name: 'notification-ntf-template', component: () => import('@/views/notification/ntf_template/ListPage.vue'), meta: { locale: 'menu.platform.notification.ntf_template', requiresAuth: true, roles: ['*'] } } + { path: 'ntf-template', name: 'notification-ntf-template', component: () => import('@/views/notification/ntf_template/ListPage.vue'), meta: { locale: 'menu.platform.notification.ntf_template', requiresAuth: true, menuCode: 'notification' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/platform.ts b/frontend/platform_admin/src/router/routes/modules/platform.ts index b58b176..94b8aa7 100644 --- a/frontend/platform_admin/src/router/routes/modules/platform.ts +++ b/frontend/platform_admin/src/router/routes/modules/platform.ts @@ -15,11 +15,11 @@ import walletRoutes from './wallet'; import reportRoutes from './report'; import auditRoutes from './audit'; const platformRoutes: AppRouteRecordRaw[] = [ - { path: '/dashboard', name: 'dashboard', component: DEFAULT_LAYOUT, meta: { locale: 'menu.dashboard', requiresAuth: true, icon: 'icon-dashboard', order: 0 }, children: [{ path: 'overview', name: 'DashboardOverview', component: () => import('@/views/dashboard/DashboardPage.vue'), meta: { locale: 'menu.platform.dashboard', requiresAuth: true, roles: ['*'] } }] }, + { path: '/dashboard', name: 'dashboard', component: DEFAULT_LAYOUT, meta: { locale: 'menu.dashboard', requiresAuth: true, icon: 'icon-dashboard', order: 0 }, children: [{ path: 'overview', name: 'DashboardOverview', component: () => import('@/views/dashboard/DashboardPage.vue'), meta: { locale: 'menu.platform.dashboard', requiresAuth: true, menuCode: 'dashboard' } }] }, { path: '/platform', name: 'platform', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.config', requiresAuth: true, icon: 'icon-settings', order: 30 }, children: [ - { path: 'platfrom-account', name: 'platform-platfrom-account', component: () => import('@/views/platform/platfrom_account/ListPage.vue'), meta: { locale: 'menu.platform.platform.platfrom_account', requiresAuth: true, roles: ['*'] } }, - { path: 'platform-role', name: 'platform-platform-role', component: () => import('@/views/platform/platform_role/ListPage.vue'), meta: { locale: 'menu.platform.platform.platform_role', requiresAuth: true, roles: ['*'] } }, - { path: 'platform-menu', name: 'platform-platform-menu', component: () => import('@/views/platform/platform_menu/TreePage.vue'), meta: { locale: 'menu.platform.platform.platform_menu', requiresAuth: true, roles: ['*'] } }, + { path: 'platfrom-account', name: 'platform-platfrom-account', component: () => import('@/views/platform/platfrom_account/ListPage.vue'), meta: { locale: 'menu.platform.platform.platfrom_account', requiresAuth: true, menuCode: 'platform' } }, + { path: 'platform-role', name: 'platform-platform-role', component: () => import('@/views/platform/platform_role/ListPage.vue'), meta: { locale: 'menu.platform.platform.platform_role', requiresAuth: true, menuCode: 'platform' } }, + { path: 'platform-menu', name: 'platform-platform-menu', component: () => import('@/views/platform/platform_menu/TreePage.vue'), meta: { locale: 'menu.platform.platform.platform_menu', requiresAuth: true, menuCode: 'platform' } }, ] }, ]; export default [...platformRoutes, ...gasRoutes, ...deliveryRoutes, ...staffRoutes, ...userRoutes, ...deviceRoutes, ...safetyRoutes, ...ecRoutes, ...financeRoutes, ...contentRoutes, ...notificationRoutes, ...customerServiceRoutes, ...walletRoutes, ...reportRoutes, ...auditRoutes]; diff --git a/frontend/platform_admin/src/router/routes/modules/report.ts b/frontend/platform_admin/src/router/routes/modules/report.ts index 8c75496..86d46c5 100644 --- a/frontend/platform_admin/src/router/routes/modules/report.ts +++ b/frontend/platform_admin/src/router/routes/modules/report.ts @@ -4,9 +4,9 @@ const routes: AppRouteRecordRaw[] = [{ path: '/report', name: 'report', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.report', requiresAuth: true, icon: 'icon-apps', order: 21 }, children: [ - { path: 'report', name: 'report-report', component: () => import('@/views/report/report/ListPage.vue'), meta: { locale: 'menu.platform.report.report', requiresAuth: true, roles: ['*'] } }, - { path: 'report-item', name: 'report-report-item', component: () => import('@/views/report/report_item/ListPage.vue'), meta: { locale: 'menu.platform.report.report_item', requiresAuth: true, roles: ['*'] } }, - { path: 'report-metric-snapshot', name: 'report-report-metric-snapshot', component: () => import('@/views/report/report_metric_snapshot/ListPage.vue'), meta: { locale: 'menu.platform.report.report_metric_snapshot', requiresAuth: true, roles: ['*'] } } + { path: 'report', name: 'report-report', component: () => import('@/views/report/report/ListPage.vue'), meta: { locale: 'menu.platform.report.report', requiresAuth: true, menuCode: 'report' } }, + { path: 'report-item', name: 'report-report-item', component: () => import('@/views/report/report_item/ListPage.vue'), meta: { locale: 'menu.platform.report.report_item', requiresAuth: true, menuCode: 'report' } }, + { path: 'report-metric-snapshot', name: 'report-report-metric-snapshot', component: () => import('@/views/report/report_metric_snapshot/ListPage.vue'), meta: { locale: 'menu.platform.report.report_metric_snapshot', requiresAuth: true, menuCode: 'report' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/safety.ts b/frontend/platform_admin/src/router/routes/modules/safety.ts index 8af490d..d45e08b 100644 --- a/frontend/platform_admin/src/router/routes/modules/safety.ts +++ b/frontend/platform_admin/src/router/routes/modules/safety.ts @@ -1,8 +1,8 @@ import { DEFAULT_LAYOUT } from '../base'; import type { AppRouteRecordRaw } from '../types'; const routes: AppRouteRecordRaw[] = [{ path: '/safety', name: 'safety', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.safety', requiresAuth: true, icon: 'icon-apps', order: 15 }, children: [ - { path: 'saf-rule', name: 'safety-saf-rule', component: () => import('@/views/safety/saf_rule/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_rule', requiresAuth: true, roles: ['*'] } }, - { path: 'saf-event', name: 'safety-saf-event', component: () => import('@/views/safety/saf_event/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_event', requiresAuth: true, roles: ['*'] } }, - { path: 'saf-inspection', name: 'safety-saf-inspection', component: () => import('@/views/safety/saf_inspection/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_inspection', requiresAuth: true, roles: ['*'] } } + { path: 'saf-rule', name: 'safety-saf-rule', component: () => import('@/views/safety/saf_rule/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_rule', requiresAuth: true, menuCode: 'safety' } }, + { path: 'saf-event', name: 'safety-saf-event', component: () => import('@/views/safety/saf_event/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_event', requiresAuth: true, menuCode: 'safety' } }, + { path: 'saf-inspection', name: 'safety-saf-inspection', component: () => import('@/views/safety/saf_inspection/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_inspection', requiresAuth: true, menuCode: 'safety' } } ] }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/staff.ts b/frontend/platform_admin/src/router/routes/modules/staff.ts index c1e0f52..1d42d6a 100644 --- a/frontend/platform_admin/src/router/routes/modules/staff.ts +++ b/frontend/platform_admin/src/router/routes/modules/staff.ts @@ -4,8 +4,8 @@ const routes: AppRouteRecordRaw[] = [{ path: '/staff', name: 'staff', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.staff', requiresAuth: true, icon: 'icon-apps', order: 12 }, children: [ - { path: 'account', name: 'staff-staff-account', component: () => import('@/views/staff/staff_account/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_account', requiresAuth: true, roles: ['*'] } }, - { path: 'credential', name: 'staff-staff-credential', component: () => import('@/views/staff/staff_credential/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_credential', requiresAuth: true, roles: ['*'] } } + { path: 'account', name: 'staff-staff-account', component: () => import('@/views/staff/staff_account/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_account', requiresAuth: true, menuCode: 'staff' } }, + { path: 'credential', name: 'staff-staff-credential', component: () => import('@/views/staff/staff_credential/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_credential', requiresAuth: true, menuCode: 'staff' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/user.ts b/frontend/platform_admin/src/router/routes/modules/user.ts index 15a6fbf..3985f6c 100644 --- a/frontend/platform_admin/src/router/routes/modules/user.ts +++ b/frontend/platform_admin/src/router/routes/modules/user.ts @@ -4,9 +4,9 @@ const routes: AppRouteRecordRaw[] = [{ path: '/user', name: 'user', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.user', requiresAuth: true, icon: 'icon-apps', order: 13 }, children: [ - { path: 'account', name: 'user-user-account', component: () => import('@/views/user/user_account/ListPage.vue'), meta: { locale: 'menu.platform.user.user_account', requiresAuth: true, roles: ['*'] } }, - { path: 'address', name: 'user-user-address', component: () => import('@/views/user/user_address/ListPage.vue'), meta: { locale: 'menu.platform.user.user_address', requiresAuth: true, roles: ['*'] } }, - { path: 'service-relation', name: 'user-user-service-relation', component: () => import('@/views/user/user_service_relation/ListPage.vue'), meta: { locale: 'menu.platform.user.user_service_relation', requiresAuth: true, roles: ['*'] } } + { path: 'account', name: 'user-user-account', component: () => import('@/views/user/user_account/ListPage.vue'), meta: { locale: 'menu.platform.user.user_account', requiresAuth: true, menuCode: 'user' } }, + { path: 'address', name: 'user-user-address', component: () => import('@/views/user/user_address/ListPage.vue'), meta: { locale: 'menu.platform.user.user_address', requiresAuth: true, menuCode: 'user' } }, + { path: 'service-relation', name: 'user-user-service-relation', component: () => import('@/views/user/user_service_relation/ListPage.vue'), meta: { locale: 'menu.platform.user.user_service_relation', requiresAuth: true, menuCode: 'user' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/wallet.ts b/frontend/platform_admin/src/router/routes/modules/wallet.ts index 5056ab4..f015319 100644 --- a/frontend/platform_admin/src/router/routes/modules/wallet.ts +++ b/frontend/platform_admin/src/router/routes/modules/wallet.ts @@ -4,10 +4,10 @@ const routes: AppRouteRecordRaw[] = [{ path: '/wallet', name: 'wallet', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.wallet', requiresAuth: true, icon: 'icon-apps', order: 20 }, children: [ - { path: 'wallet', name: 'wallet-wallet', component: () => import('@/views/wallet/wallet/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet', requiresAuth: true, roles: ['*'] } }, - { path: 'wallet-ledger', name: 'wallet-wallet-ledger', component: () => import('@/views/wallet/wallet_ledger/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_ledger', requiresAuth: true, roles: ['*'] } }, - { path: 'wallet-recharge', name: 'wallet-wallet-recharge', component: () => import('@/views/wallet/wallet_recharge/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_recharge', requiresAuth: true, roles: ['*'] } }, - { path: 'wallet-withdrawal', name: 'wallet-wallet-withdrawal', component: () => import('@/views/wallet/wallet_withdrawal/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_withdrawal', requiresAuth: true, roles: ['*'] } } + { path: 'wallet', name: 'wallet-wallet', component: () => import('@/views/wallet/wallet/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet', requiresAuth: true, menuCode: 'wallet' } }, + { path: 'wallet-ledger', name: 'wallet-wallet-ledger', component: () => import('@/views/wallet/wallet_ledger/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_ledger', requiresAuth: true, menuCode: 'wallet' } }, + { path: 'wallet-recharge', name: 'wallet-wallet-recharge', component: () => import('@/views/wallet/wallet_recharge/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_recharge', requiresAuth: true, menuCode: 'wallet' } }, + { path: 'wallet-withdrawal', name: 'wallet-wallet-withdrawal', component: () => import('@/views/wallet/wallet_withdrawal/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_withdrawal', requiresAuth: true, menuCode: 'wallet' } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/typings.d.ts b/frontend/platform_admin/src/router/typings.d.ts index 5ccaa70..c95eed3 100644 --- a/frontend/platform_admin/src/router/typings.d.ts +++ b/frontend/platform_admin/src/router/typings.d.ts @@ -3,6 +3,7 @@ import 'vue-router'; declare module 'vue-router' { interface RouteMeta { roles?: string[]; // Controls roles that have access to the page + menuCode?: string; // Server-assigned menu domain required by this route requiresAuth: boolean; // Whether login is required to access the current page (every route must declare) icon?: string; // The icon show in the side menu locale?: string; // The locale name show in side menu and breadcrumb diff --git a/frontend/platform_admin/src/store/modules/user/index.ts b/frontend/platform_admin/src/store/modules/user/index.ts index 4cc6350..64cc041 100644 --- a/frontend/platform_admin/src/store/modules/user/index.ts +++ b/frontend/platform_admin/src/store/modules/user/index.ts @@ -24,6 +24,7 @@ const useUserStore = defineStore('user', { accountId: undefined, certification: undefined, role: '', + menuCodes: [], }), getters: { @@ -55,7 +56,13 @@ const useUserStore = defineStore('user', { // Get user's information async info() { const profile = await authApi.profile(); - this.setInfo({ name: profile.display_name || profile.username, avatar: profile.avatar, accountId: profile.identity, role: '*' }); + this.setInfo({ + name: profile.display_name || profile.username, + avatar: profile.avatar, + accountId: profile.identity, + role: profile.role_code, + menuCodes: profile.menu_codes, + }); }, // Login diff --git a/frontend/platform_admin/src/store/modules/user/types.ts b/frontend/platform_admin/src/store/modules/user/types.ts index 75fd784..cbafa7e 100644 --- a/frontend/platform_admin/src/store/modules/user/types.ts +++ b/frontend/platform_admin/src/store/modules/user/types.ts @@ -1,4 +1,4 @@ -export type RoleType = '' | '*' | 'admin' | 'user'; +export type RoleType = string; export interface UserState { name?: string; avatar?: string; @@ -16,4 +16,5 @@ export interface UserState { accountId?: string; certification?: number; role: RoleType; + menuCodes: string[]; } diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index 7dec315..c0da12c 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -39,6 +39,9 @@ + + {{ role.name }} + @@ -59,6 +62,9 @@ + + {{ menu.name }} + @@ -69,6 +75,7 @@ import { Message, Modal } from '@arco-design/web-vue'; import { computed, onMounted, reactive, ref } from 'vue'; import { resourceApi } from '@/api/resource'; +import { platformApi, type PlatformMenu, type PlatformRole } from '@/api/platform'; import { buildResourcePayload, isMissingField, @@ -92,6 +99,8 @@ const detail = ref({}); const actionVisible = ref(false); const activeAction = ref(); const actionForm = reactive>({}); +const menuOptions = ref([]); +const roleOptions = ref([]); const canCreate = computed(() => props.definition.mode !== 'readonly'); const canEdit = computed(() => props.definition.mode === 'writable'); const canArchive = computed(() => props.definition.mode === 'writable'); @@ -166,9 +175,23 @@ async function openDetail(row: Row) { } } -function openDetailAction(action: DetailAction) { +async function openDetailAction(action: DetailAction) { activeAction.value = action; for (const field of action.fields) actionForm[field.key] = undefined; + if (action.fields.some((field) => field.type === 'menu-identities')) { + try { + const identity = String(detail.value.identity); + const [menus, assigned] = await Promise.all([ + platformApi.listMenu(), + platformApi.listRoleMenuIdentities(identity), + ]); + menuOptions.value = menus.list; + actionForm.menu_identities = assigned.menu_identities; + } catch (error) { + Message.error((error as Error).message); + return; + } + } actionVisible.value = true; } @@ -188,10 +211,17 @@ async function submitDetailAction() { ...action.payload, ...buildResourcePayload(action.fields, actionForm), }; - await resourceApi.create( - action.resource.replace(':identity', String(detail.value.identity)), - payload, - ); + if (action.fields.some((field) => field.type === 'menu-identities')) { + await platformApi.replaceRoleMenus( + String(detail.value.identity), + payload.menu_identities as string[], + ); + } else { + await resourceApi.create( + action.resource.replace(':identity', String(detail.value.identity)), + payload, + ); + } Message.success('操作成功'); actionVisible.value = false; await openDetail(detail.value); @@ -256,7 +286,15 @@ async function changePage(next: number) { await load(); } -onMounted(load); +onMounted(async () => { + await load(); + if (props.definition.fields.some((field) => field.type === 'role-code')) { + const result = await platformApi.listRole(); + roleOptions.value = result.list.filter( + (role) => !role.is_system && role.status === 'enabled', + ); + } +});