fix: enforce platform role menu access
This commit is contained in:
82
backend/api/internal/logic/platform/access.go
Normal file
82
backend/api/internal/logic/platform/access.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`)).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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['"]/);
|
||||
});
|
||||
|
||||
@@ -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<LoginReply>('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
|
||||
|
||||
@@ -11,4 +11,16 @@ export const platformApi = {
|
||||
listRole: () => resourceApi.list<PlatformRole>('/platform/platform_role'),
|
||||
createRole: (data: Record<string, unknown>) => resourceApi.create<PlatformRole>('/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 }),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,6 +4,8 @@ export type ResourceFieldType =
|
||||
| 'text'
|
||||
| 'password'
|
||||
| 'identity'
|
||||
| 'role-code'
|
||||
| 'menu-identities'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
@@ -105,6 +107,7 @@ const labels: Record<string, string> = {
|
||||
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!',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
<a-date-picker v-else-if="field.type === 'datetime'" v-model="form[field.key]" show-time value-format="YYYY-MM-DDTHH:mm:ssZ" />
|
||||
<a-textarea v-else-if="field.type === 'json' || field.type === 'textarea'" v-model="form[field.key]" :auto-size="{ minRows: 3, maxRows: 8 }" />
|
||||
<a-input-password v-else-if="field.type === 'password'" v-model="form[field.key]" />
|
||||
<a-select v-else-if="field.type === 'role-code'" v-model="form[field.key]">
|
||||
<a-option v-for="role in roleOptions" :key="role.role_code" :value="role.role_code">{{ role.name }}</a-option>
|
||||
</a-select>
|
||||
<a-input v-else v-model="form[field.key]" :placeholder="`请输入${field.label}`" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
@@ -59,6 +62,9 @@
|
||||
<a-input-number v-if="field.type === 'number'" v-model="actionForm[field.key]" />
|
||||
<a-switch v-else-if="field.type === 'boolean'" v-model="actionForm[field.key]" />
|
||||
<a-textarea v-else-if="field.type === 'json' || field.type === 'textarea'" v-model="actionForm[field.key]" />
|
||||
<a-select v-else-if="field.type === 'menu-identities'" v-model="actionForm[field.key]" multiple allow-search>
|
||||
<a-option v-for="menu in menuOptions" :key="menu.identity" :value="menu.identity">{{ menu.name }}</a-option>
|
||||
</a-select>
|
||||
<a-input v-else v-model="actionForm[field.key]" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
@@ -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<Row>({});
|
||||
const actionVisible = ref(false);
|
||||
const activeAction = ref<DetailAction>();
|
||||
const actionForm = reactive<Record<string, any>>({});
|
||||
const menuOptions = ref<PlatformMenu[]>([]);
|
||||
const roleOptions = ref<PlatformRole[]>([]);
|
||||
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',
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
Reference in New Issue
Block a user