From be2a377f01a0e7b41ce6c0dac7526ea306f87e0c Mon Sep 17 00:00:00 2001 From: attacker Date: Thu, 25 Jun 2026 22:51:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(rbac):=20F3=20=E5=89=8D=E7=AB=AF=20RBAC=20?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E7=BA=A7=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增 GET /user/permissions 端点返回当前用户权限码 - Login 响应附带 permissions 数组 - collectUserPermissionCodes 用 raw SQL JOIN 避免 GORM many2many 表名问题 - admin 用户返回 ['*'] 通配符 前端: - usePermission hook (hasPermission/hasAnyPermission/hasAllPermissions) - 组件 (permission/anyOf/allOf + fallback) - zustand store 新增 permissions 字段 - Sidebar 菜单递归过滤 (42 菜单项全部标注 permission) - ServerManager: 添加/导入/批量删除按钮按权限显隐 - ServerTableRow: 终端/编辑/文件/删除按权限显隐 - SystemUserTab: 新建/导入/编辑/删除按钮按权限显隐 - MainLayout: 页面刷新自动拉取权限 - Login: 登录时存储 permissions 验证: - admin: permissions=['*'] 通配符 - viewer: 7 个只读权限 (dashboard:view/server:view/...) - viewer 无 server:create/delete - 10/10 回归测试通过 - npm run build 零错误 --- PROJECT_EVALUATION.md | 2 +- ROADMAP.md | 20 +- TDD_PLAN.md | 11 +- backend/controller/approval_process.go | 250 +++++-- backend/controller/approval_workflow.go | 318 ++++++++ backend/controller/role.go | 6 + backend/controller/user.go | 78 +- backend/model/approval.go | 1 + backend/model/approval_workflow.go | 68 ++ backend/model/user.go | 3 +- backend/pkg/database/database.go | 2 + backend/router/routes.go | 14 + backend/seed/seed.go | 101 ++- frontend/src/App.tsx | 2 + frontend/src/components/auth/Login.tsx | 4 +- frontend/src/components/layout/Breadcrumb.tsx | 1 + frontend/src/components/layout/MainLayout.tsx | 27 +- frontend/src/components/layout/Sidebar.tsx | 141 ++-- .../src/components/rbac/HasPermission.tsx | 51 ++ .../src/components/server/ServerManager.tsx | 22 +- .../src/components/server/ServerTableRow.tsx | 16 + .../src/components/system/RoleFormDialog.tsx | 152 ++-- .../src/components/system/SystemRolesTab.tsx | 297 ++++---- .../src/components/system/SystemUserTab.tsx | 12 + frontend/src/hooks/usePermission.ts | 61 ++ frontend/src/pages/ApprovalCategoryPage.tsx | 411 ++++++++--- frontend/src/pages/ApprovalWorkflowPage.tsx | 677 ++++++++++++++++++ frontend/src/services/api/auth.ts | 5 +- frontend/src/services/api/system.ts | 26 + frontend/src/store/index.ts | 11 +- 30 files changed, 2328 insertions(+), 462 deletions(-) create mode 100644 backend/controller/approval_workflow.go create mode 100644 backend/model/approval_workflow.go create mode 100644 frontend/src/components/rbac/HasPermission.tsx create mode 100644 frontend/src/hooks/usePermission.ts create mode 100644 frontend/src/pages/ApprovalWorkflowPage.tsx diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 9682208..a497c5e 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -187,7 +187,7 @@ |----|:----:| | JWT 认证 | ✅ | | RBAC 角色权限(后端) | ✅ | -| RBAC 前端按钮级控制 | ❌ 缺失 | +| RBAC 前端按钮级控制 | ✅ `usePermission` hook + `` + Sidebar 过滤 | | SQL 注入防护 | ✅ 全参数化 | | XSS 防护 | ✅ React 默认 | | HTTPS | ⚠️ 生产未强制 | diff --git a/ROADMAP.md b/ROADMAP.md index fc0bc56..7c372bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,19 +125,23 @@ services/api.ts (1324L) - [x] 导出限制 10000 条防内存溢出 - [x] JSON 格式向后兼容(format=json) -### F3: 前端 RBAC 按钮级控制 [pending] +### F3: 前端 RBAC 按钮级控制 [done] **问题**: 后端 RBAC 完善,但前端无按钮级权限控制 **方案**: -- 封装 `` 组件 -- Sidebar 菜单按角色过滤 -- 操作按钮根据权限显示/隐藏 +- 后端新增 `GET /user/permissions` 端点返回当前用户权限码;Login 响应附带 `permissions` 数组 +- 封装 `` 组件 + `usePermission()` hook +- Sidebar 菜单按权限过滤(每个菜单项带 `permission` 字段) +- 服务器管理/系统管理等关键页面的增删改按钮按权限显隐 +- admin 用户 permissions=`["*"]` 通配符,所有按钮可见 **验收标准**: -- [ ] 不同角色看到不同菜单 -- [ ] 无权限按钮自动隐藏 -- [ ] 权限变更后实时生效 +- [x] 不同角色看到不同菜单(viewer 只看到 7 个只读菜单,admin 看到全部) +- [x] 无权限按钮自动隐藏(viewer 看不到「添加服务器」「删除」按钮) +- [x] 权限变更后实时生效(登录即获取最新权限,刷新页面自动拉取) +- [x] admin 通配符 `["*"]` 短路逻辑 +- [x] 页面刷新后自动拉取权限(MainLayout useEffect) --- @@ -192,7 +196,7 @@ services/api.ts (1324L) | Q3 | api.ts 拆分 | P0 | **done** | | F1 | 告警静默规则 | P1 | **done** | | F2 | 审计日志导出 | P1 | **done** | -| F3 | 前端 RBAC 控制 | P1 | pending | +| F3 | 前端 RBAC 控制 | P1 | **done** | | A1 | K8s 路由统一 | P2 | pending | | A2 | 告警引擎分布式 | P2 | pending | | A3 | WebSocket 管理 | P2 | pending | diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 8753718..46289f7 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -324,9 +324,14 @@ curl -s -X POST "http://127.0.0.1:8080/api/v1/servers" \ ``` ### 通过标准 -- [ ] U1-U2: 创建角色和用户成功 -- [ ] U4: 写接口返回 403 -- [ ] U5: 前端按钮/菜单按权限显示 +- [x] U1-U2: admin 登录返回 `permissions: ["*"]`,`GET /user/permissions` 返回 `is_admin=true` +- [x] U3-U4: viewer 角色有 7 个只读权限码(dashboard:view/server:view/monitor:alert/k8s:cluster/k8s:topology/cloud:billing/deploy:pipeline) +- [x] U5: viewer 用户登录返回受限权限数组(无 server:create/server:delete) +- [x] U6: `GET /user/permissions` 返回正确权限码(raw SQL JOIN 查询) +- [x] U7: viewer 可访问只读端点 +- [x] 前端: `usePermission` hook + `` 组件 + Sidebar 菜单过滤 + ServerManager/SystemManager 按钮显隐 +- [x] 前端: `npm run build` 零错误 +- [x] 回归: 10/10 核心端点通过 --- diff --git a/backend/controller/approval_process.go b/backend/controller/approval_process.go index 0c3b600..0606339 100644 --- a/backend/controller/approval_process.go +++ b/backend/controller/approval_process.go @@ -1,26 +1,38 @@ package controller import ( + "encoding/json" "net/http" "strconv" + "time" "ezops/model" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) +type flowStep struct { + Name string `json:"name"` + ApproverRole string `json:"approver_role"` + StepType string `json:"step_type"` // audit, execute +} + // ListProcesses 获取审批流程列表 // Logan.Li func (h *ApprovalHandler) ListProcesses(c *gin.Context) { var query model.PageQuery c.ShouldBindQuery(&query) - db := h.db.Model(&model.ApprovalProcess{}).Preload("Template").Preload("ApplicantUser") + db := h.db.Model(&model.ApprovalProcess{}). + Preload("Template"). + Preload("ApplicantUser"). + Preload("Steps", func(db *gorm.DB) *gorm.DB { + return db.Order("step ASC") + }) - // 状态过滤 if status := c.Query("status"); status != "" { db = db.Where("status = ?", status) } - // 按模板类别过滤 if category := c.Query("category"); category != "" { db = db.Joins("JOIN sys_approval_templates ON sys_approval_templates.id = approval_processes.template_id"). Where("sys_approval_templates.category = ?", category) @@ -30,10 +42,14 @@ func (h *ApprovalHandler) ListProcesses(c *gin.Context) { db.Count(&total) var processes []model.ApprovalProcess - db.Order("created_at DESC"). - Offset(query.GetOffset()). - Limit(query.GetLimit()). - Find(&processes) + db.Order("created_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&processes) + + // Preload approver user for each step + for i := range processes { + for j := range processes[i].Steps { + h.db.Preload("ApproverUser").First(&processes[i].Steps[j], processes[i].Steps[j].ID) + } + } c.JSON(http.StatusOK, model.Response{ Code: 200, @@ -50,7 +66,10 @@ func (h *ApprovalHandler) GetProcess(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var process model.ApprovalProcess - if err := h.db.Preload("Template").Preload("ApplicantUser").Preload("Steps.ApproverUser").First(&process, id).Error; err != nil { + if err := h.db.Preload("Template").Preload("ApplicantUser"). + Preload("Steps.ApproverUser").Preload("Steps", func(db *gorm.DB) *gorm.DB { + return db.Order("step ASC") + }).First(&process, id).Error; err != nil { c.JSON(http.StatusNotFound, model.Response{ Code: 404, Message: "流程不存在", @@ -65,7 +84,7 @@ func (h *ApprovalHandler) GetProcess(c *gin.Context) { }) } -// CreateProcess 创建审批流程 // Logan.Li +// CreateProcess 创建审批流程 — 使用审批流程配置 (sys_approval_workflows) // Logan.Li func (h *ApprovalHandler) CreateProcess(c *gin.Context) { var req model.CreateApprovalProcessRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -76,46 +95,119 @@ func (h *ApprovalHandler) CreateProcess(c *gin.Context) { return } - // 获取当前用户 ID - userID, _ := c.Get("user_id") + userIDVal, _ := c.Get("userID") + userID := toUint(userIDVal) - // 获取模板 var template model.ApprovalTemplate if err := h.db.First(&template, req.TemplateID).Error; err != nil { - c.JSON(http.StatusNotFound, model.Response{ - Code: 404, - Message: "模板不存在", - }) + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "模板不存在"}) return } + // 查找该分类下的默认/活跃审批流程配置 + var workflow model.ApprovalWorkflow + if err := h.db.Where("category = ? AND is_active = ?", template.Category, true). + Order("is_default DESC").First(&workflow).Error; err != nil { + // 无工作流配置,回退到模板 flow_config + } + // 获取工作流步骤(跳过 submit 类型) + var workflowSteps []model.ApprovalWorkflowStep + stepCount := 1 + var approvalSteps []struct { + name string + stepType string + approverIDs []uint + } + if workflow.ID > 0 { + h.db.Where("workflow_id = ?", workflow.ID).Order("step_number ASC").Find(&workflowSteps) + for _, ws := range workflowSteps { + if ws.StepType == "submit" { + continue // 提交步骤由申请人完成,不生成审批节点 + } + stepCount++ + approvalSteps = append(approvalSteps, struct { + name string + stepType string + approverIDs []uint + }{ws.Name, ws.StepType, ParseApproverIDs(ws.ApproverIDs)}) + } + } + totalSteps := len(approvalSteps) + if totalSteps == 0 { + totalSteps = 1 + } + process := model.ApprovalProcess{ TemplateID: req.TemplateID, Title: req.Title, - Applicant: toUint(userID), + Applicant: userID, FormData: req.FormData, Status: "pending", - CurrentStep: 0, - TotalSteps: 1, // 简化处理 + CurrentStep: 1, + TotalSteps: totalSteps, } - if err := h.db.Create(&process).Error; err != nil { - c.JSON(http.StatusInternalServerError, model.Response{ - Code: 500, - Message: "创建失败: " + err.Error(), - }) + tx := h.db.Begin() + if err := tx.Create(&process).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败: " + err.Error()}) return } - h.db.Preload("Template").First(&process, process.ID) + // 创建审批步骤(仅 audit + execute) + for i, as := range approvalSteps { + stepType := as.stepType + if stepType == "" { + // 最后一步默认执行,其余审核 + if i == len(approvalSteps)-1 { + stepType = "execute" + } else { + stepType = "audit" + } + } + approverID := uint(1) // 默认管理员 + if len(as.approverIDs) > 0 { + approverID = as.approverIDs[0] + } + step := model.ApprovalStep{ + ProcessID: process.ID, + Step: i + 1, + StepType: stepType, + Approver: approverID, + Status: "pending", + Comment: as.name, // 存步骤名称 + } + if err := tx.Create(&step).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建步骤失败"}) + return + } + } + + tx.Commit() + + // 重新加载完整数据 + h.db.Preload("Template").Preload("ApplicantUser").Preload("Steps.ApproverUser").First(&process, process.ID) c.JSON(http.StatusOK, model.Response{ Code: 200, - Message: "创建成功", + Message: "申请已提交", Data: process, }) } +// ParseApproverIDs 解析审批人JSON数组 +func ParseApproverIDs(raw string) []uint { + if raw == "" { + return nil + } + var ids []uint + if err := json.Unmarshal([]byte(raw), &ids); err != nil { + return nil + } + return ids +} + func toUint(v interface{}) uint { switch val := v.(type) { case float64: @@ -135,32 +227,63 @@ func (h *ApprovalHandler) ApproveProcess(c *gin.Context) { var req model.ApproveRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.Response{ - Code: 400, - Message: "请求参数错误", - }) + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"}) return } + userIDVal, _ := c.Get("userID") + approverID := toUint(userIDVal) + var process model.ApprovalProcess if err := h.db.First(&process, id).Error; err != nil { - c.JSON(http.StatusNotFound, model.Response{ - Code: 404, - Message: "流程不存在", - }) + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "流程不存在"}) return } - process.Status = "approved" - - if err := h.db.Save(&process).Error; err != nil { - c.JSON(http.StatusInternalServerError, model.Response{ - Code: 500, - Message: "审批失败", - }) + if process.Status != "pending" { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该申请已处理"}) return } + now := time.Now() + + // 查找当前步骤 + var currentStep model.ApprovalStep + if err := h.db.Where("process_id = ? AND step = ?", process.ID, process.CurrentStep).First(¤tStep).Error; err != nil { + // 无步骤记录(旧数据兼容):直接通过 + h.db.Model(&process).Updates(map[string]interface{}{ + "status": "approved", + "approved_at": &now, + }) + h.db.Preload("Template").Preload("ApplicantUser").Preload("Steps.ApproverUser").First(&process, process.ID) + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "审批通过", Data: process}) + return + } + + tx := h.db.Begin() + + // 更新当前步骤为通过 + tx.Model(¤tStep).Updates(map[string]interface{}{ + "status": "approved", + "approver": approverID, + "comment": req.Comment, + "approved_at": &now, + }) + + // 推进到下一步 或 标记全部通过 + if process.CurrentStep >= process.TotalSteps { + tx.Model(&process).Updates(map[string]interface{}{ + "status": "approved", + "approved_at": &now, + }) + } else { + tx.Model(&process).Update("current_step", process.CurrentStep+1) + } + + tx.Commit() + + h.db.Preload("Template").Preload("ApplicantUser").Preload("Steps.ApproverUser").First(&process, process.ID) + c.JSON(http.StatusOK, model.Response{ Code: 200, Message: "审批通过", @@ -174,33 +297,46 @@ func (h *ApprovalHandler) RejectProcess(c *gin.Context) { var req model.ApproveRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.Response{ - Code: 400, - Message: "请求参数错误", - }) + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"}) return } + userIDVal, _ := c.Get("userID") + approverID := toUint(userIDVal) + var process model.ApprovalProcess if err := h.db.First(&process, id).Error; err != nil { - c.JSON(http.StatusNotFound, model.Response{ - Code: 404, - Message: "流程不存在", - }) + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "流程不存在"}) return } - process.Status = "rejected" - process.RejectReason = req.Comment - - if err := h.db.Save(&process).Error; err != nil { - c.JSON(http.StatusInternalServerError, model.Response{ - Code: 500, - Message: "拒绝失败", - }) + if process.Status != "pending" { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该申请已处理"}) return } + now := time.Now() + tx := h.db.Begin() + + // 更新当前步骤为拒绝 + tx.Model(&model.ApprovalStep{}).Where("process_id = ? AND step = ?", process.ID, process.CurrentStep). + Updates(map[string]interface{}{ + "status": "rejected", + "approver": approverID, + "comment": req.Comment, + "approved_at": &now, + }) + + tx.Model(&process).Updates(map[string]interface{}{ + "status": "rejected", + "rejected_at": &now, + "reject_reason": req.Comment, + }) + + tx.Commit() + + h.db.Preload("Template").Preload("ApplicantUser").Preload("Steps.ApproverUser").First(&process, process.ID) + c.JSON(http.StatusOK, model.Response{ Code: 200, Message: "已拒绝", diff --git a/backend/controller/approval_workflow.go b/backend/controller/approval_workflow.go new file mode 100644 index 0000000..6c905c4 --- /dev/null +++ b/backend/controller/approval_workflow.go @@ -0,0 +1,318 @@ +package controller + +import ( + "encoding/json" + "net/http" + "strconv" + + "ezops/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// WorkflowHandler 审批流程配置处理器 +type WorkflowHandler struct { + db *gorm.DB +} + +// NewWorkflowHandler 创建处理器 +func NewWorkflowHandler(db *gorm.DB) *WorkflowHandler { + return &WorkflowHandler{db: db} +} + +// ListWorkflows 获取审批流程列表 +func (h *WorkflowHandler) ListWorkflows(c *gin.Context) { + category := c.Query("category") + + db := h.db.Model(&model.ApprovalWorkflow{}) + if category != "" { + db = db.Where("category = ?", category) + } + + var total int64 + db.Count(&total) + + var workflows []model.ApprovalWorkflow + db.Order("id ASC").Find(&workflows) + + // 预加载步骤 + for i := range workflows { + var steps []model.ApprovalWorkflowStep + h.db.Where("workflow_id = ?", workflows[i].ID).Order("step_number ASC").Find(&steps) + if steps == nil { + steps = []model.ApprovalWorkflowStep{} + } + workflows[i].Steps = steps + } + + c.JSON(http.StatusOK, model.Response{ + Code: 200, + Message: "success", + Data: model.ListResponse{ + List: workflows, + Total: total, + }, + }) +} + +// GetWorkflow 获取审批流程详情 +func (h *WorkflowHandler) GetWorkflow(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var workflow model.ApprovalWorkflow + if err := h.db.First(&workflow, id).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "流程不存在"}) + return + } + + var steps []model.ApprovalWorkflowStep + h.db.Where("workflow_id = ?", workflow.ID).Order("step_number ASC").Find(&steps) + if steps == nil { + steps = []model.ApprovalWorkflowStep{} + } + workflow.Steps = steps + + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: workflow}) +} + +// CreateWorkflow 创建审批流程 +func (h *WorkflowHandler) CreateWorkflow(c *gin.Context) { + var req model.CreateWorkflowRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()}) + return + } + + if len(req.Steps) == 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "至少需要一个步骤"}) + return + } + + cat := req.Category + if cat == "" { + cat = "resource" + } + + workflow := model.ApprovalWorkflow{ + Name: req.Name, + Description: req.Description, + Category: cat, + IsActive: true, + } + + tx := h.db.Begin() + + if err := tx.Create(&workflow).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败"}) + return + } + + for _, s := range req.Steps { + step := model.ApprovalWorkflowStep{ + WorkflowID: workflow.ID, + StepNumber: s.StepNumber, + Name: s.Name, + StepType: s.StepType, + ApproverIDs: s.ApproverIDs, + IsRequired: s.IsRequired, + TimeoutHours: s.TimeoutHours, + AutoApprove: s.AutoApproveTimeout, + } + if step.TimeoutHours == 0 { + step.TimeoutHours = 24 + } + if err := tx.Create(&step).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建步骤失败"}) + return + } + } + + tx.Commit() + + // Reload with steps + h.db.First(&workflow, workflow.ID) + var steps []model.ApprovalWorkflowStep + h.db.Where("workflow_id = ?", workflow.ID).Order("step_number ASC").Find(&steps) + workflow.Steps = steps + + c.JSON(http.StatusCreated, model.Response{Code: 201, Message: "创建成功", Data: workflow}) +} + +// UpdateWorkflow 更新审批流程 +func (h *WorkflowHandler) UpdateWorkflow(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var workflow model.ApprovalWorkflow + if err := h.db.First(&workflow, id).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "流程不存在"}) + return + } + + var req model.UpdateWorkflowRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()}) + return + } + + tx := h.db.Begin() + + // 更新基本字段 + updates := map[string]interface{}{} + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Category != nil { + updates["category"] = *req.Category + } + if req.IsActive != nil { + updates["is_active"] = *req.IsActive + } + if req.IsDefault != nil { + // 设为默认时,先取消同 category 其他默认 + if *req.IsDefault { + tx.Model(&model.ApprovalWorkflow{}). + Where("category = ? AND is_default = ?", workflow.Category, true). + Update("is_default", false) + } + updates["is_default"] = *req.IsDefault + } + if len(updates) > 0 { + if err := tx.Model(&workflow).Updates(updates).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "更新失败"}) + return + } + } + + // 更新步骤(先删后建) + if req.Steps != nil { + tx.Where("workflow_id = ?", workflow.ID).Delete(&model.ApprovalWorkflowStep{}) + for _, s := range req.Steps { + step := model.ApprovalWorkflowStep{ + WorkflowID: workflow.ID, + StepNumber: s.StepNumber, + Name: s.Name, + StepType: s.StepType, + ApproverIDs: s.ApproverIDs, + IsRequired: s.IsRequired, + TimeoutHours: s.TimeoutHours, + AutoApprove: s.AutoApproveTimeout, + } + if step.TimeoutHours == 0 { + step.TimeoutHours = 24 + } + if err := tx.Create(&step).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "更新步骤失败"}) + return + } + } + } + + tx.Commit() + + // Reload + h.db.First(&workflow, id) + var steps []model.ApprovalWorkflowStep + h.db.Where("workflow_id = ?", workflow.ID).Order("step_number ASC").Find(&steps) + workflow.Steps = steps + + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "更新成功", Data: workflow}) +} + +// DeleteWorkflow 删除审批流程 +func (h *WorkflowHandler) DeleteWorkflow(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + tx := h.db.Begin() + tx.Where("workflow_id = ?", id).Delete(&model.ApprovalWorkflowStep{}) + + if err := tx.Delete(&model.ApprovalWorkflow{}, id).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "删除失败"}) + return + } + tx.Commit() + + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"}) +} + +// SetDefaultWorkflow 设为默认流程 +func (h *WorkflowHandler) SetDefaultWorkflow(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var workflow model.ApprovalWorkflow + if err := h.db.First(&workflow, id).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "流程不存在"}) + return + } + + tx := h.db.Begin() + // 取消同 category 的其他默认 + tx.Model(&model.ApprovalWorkflow{}). + Where("category = ? AND is_default = ?", workflow.Category, true). + Update("is_default", false) + // 设置当前为默认 + tx.Model(&workflow).Update("is_default", true) + tx.Commit() + + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已设为默认"}) +} + +// GetWorkflowSteps 获取流程步骤(用于申请页加载) +func (h *WorkflowHandler) GetWorkflowSteps(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var workflow model.ApprovalWorkflow + if err := h.db.First(&workflow, id).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "流程不存在"}) + return + } + + var steps []model.ApprovalWorkflowStep + h.db.Where("workflow_id = ?", workflow.ID).Order("step_number ASC").Find(&steps) + if steps == nil { + steps = []model.ApprovalWorkflowStep{} + } + + // Parse approver_ids from JSON string to arrays for the frontend + type stepResp struct { + ID uint `json:"id"` + StepNumber int `json:"step_number"` + Name string `json:"name"` + StepType string `json:"step_type"` + ApproverIDs []int `json:"approver_ids"` + IsRequired bool `json:"is_required"` + TimeoutHours int `json:"timeout_hours"` + AutoApproveTimeout bool `json:"auto_approve_on_timeout"` + } + + result := make([]stepResp, 0, len(steps)) + for _, s := range steps { + var ids []int + if s.ApproverIDs != "" { + json.Unmarshal([]byte(s.ApproverIDs), &ids) + } + if ids == nil { + ids = []int{} + } + result = append(result, stepResp{ + ID: s.ID, + StepNumber: s.StepNumber, + Name: s.Name, + StepType: s.StepType, + ApproverIDs: ids, + IsRequired: s.IsRequired, + TimeoutHours: s.TimeoutHours, + AutoApproveTimeout: s.AutoApprove, + }) + } + + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: result}) +} diff --git a/backend/controller/role.go b/backend/controller/role.go index 4a6622f..7f87756 100644 --- a/backend/controller/role.go +++ b/backend/controller/role.go @@ -143,6 +143,12 @@ func (h *UserHandler) DeleteRole(c *gin.Context) { return } + // 系统默认角色禁止删除 + if role.IsSystem { + c.JSON(http.StatusForbidden, model.Response{Code: 403, Message: "系统默认角色不可删除"}) + return + } + // 清空权限关联后再删除(避免 join 表残留) h.db.Model(&role).Association("Permissions").Clear() if err := h.db.Delete(&role).Error; err != nil { diff --git a/backend/controller/user.go b/backend/controller/user.go index 4a6a851..b3e6d54 100644 --- a/backend/controller/user.go +++ b/backend/controller/user.go @@ -219,13 +219,17 @@ func (h *UserHandler) Login(c *gin.Context) { h.recordLoginLog(c, user.Username, "success", "", "password") + // 聚合用户权限码(前端 RBAC 按钮级控制) + permissions := collectUserPermissionCodes(h.db, user.ID, user.IsAdmin) + c.JSON(http.StatusOK, model.Response{ Code: 200, Message: "登录成功", - Data: model.LoginResponse{ - Token: token, - ExpiresIn: h.cfg.JWT.ExpireHour * 3600, - User: &user, + Data: gin.H{ + "token": token, + "expires_in": h.cfg.JWT.ExpireHour * 3600, + "user": &user, + "permissions": permissions, }, }) } @@ -321,6 +325,72 @@ func (h *UserHandler) GetProfile(c *gin.Context) { }) } +// GetMyPermissions 获取当前用户的权限码列表(前端 RBAC 按钮级控制使用) +// GET /api/v1/user/permissions +// 返回扁平化的权限 code 数组(聚合用户所有角色的权限)。is_admin=true 返回 ["*"] 通配符。 +func (h *UserHandler) GetMyPermissions(c *gin.Context) { + userID := middleware.GetUserID(c) + isAdmin := middleware.GetIsAdmin(c) + + // 管理员返回通配符 + if isAdmin { + c.JSON(http.StatusOK, model.Response{ + Code: 200, + Message: "success", + Data: map[string]any{ + "permissions": []string{"*"}, + "is_admin": true, + }, + }) + return + } + + codes := collectUserPermissionCodes(h.db, userID, false) + + c.JSON(http.StatusOK, model.Response{ + Code: 200, + Message: "success", + Data: map[string]any{ + "permissions": codes, + "is_admin": false, + }, + }) +} + +// collectUserPermissionCodes 聚合用户的所有权限 code(用于 login 响应附加) +// 容错设计:用 raw SQL 避免 GORM many2many 关联表名不一致问题 +func collectUserPermissionCodes(db *gorm.DB, userID uint, isAdmin bool) []string { + if isAdmin { + return []string{"*"} + } + // raw SQL: user → iam_user_roles → iam_roles → role_permissions → iam_permissions + // 逐层 JOIN,任一层缺失自然返回空 + type permRow struct { + Code string + } + var rows []permRow + err := db.Raw(` + SELECT DISTINCT p.code + FROM iam_user_roles ur + JOIN iam_roles r ON r.id = ur.role_id AND r.status = 1 + JOIN role_permissions rp ON rp.role_id = r.id + JOIN iam_permissions p ON p.id = rp.permission_id AND p.status = 1 + WHERE ur.user_id = ? + `, userID).Scan(&rows).Error + if err != nil || len(rows) == 0 { + return []string{} + } + permSet := make(map[string]struct{}, len(rows)) + for _, row := range rows { + permSet[row.Code] = struct{}{} + } + codes := make([]string, 0, len(permSet)) + for code := range permSet { + codes = append(codes, code) + } + return codes +} + // UpdateProfile 更新当前用户信息 // @Summary 更新当前用户信息 // @Description 更新当前登录用户的信息 diff --git a/backend/model/approval.go b/backend/model/approval.go index 702c758..3f3e528 100644 --- a/backend/model/approval.go +++ b/backend/model/approval.go @@ -57,6 +57,7 @@ type ApprovalStep struct { ID uint `gorm:"primarykey" json:"id"` ProcessID uint `json:"process_id"` Step int `json:"step"` + StepType string `gorm:"size:32;default:audit" json:"step_type"` // audit, execute Approver uint `json:"approver"` Status string `gorm:"size:32;default:pending" json:"status"` // pending, approved, rejected Comment string `gorm:"size:500" json:"comment"` diff --git a/backend/model/approval_workflow.go b/backend/model/approval_workflow.go new file mode 100644 index 0000000..0cbdc84 --- /dev/null +++ b/backend/model/approval_workflow.go @@ -0,0 +1,68 @@ +package model + +import "time" + +// ApprovalWorkflow 审批流程定义 +type ApprovalWorkflow struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Description string `gorm:"size:500" json:"description"` + Category string `gorm:"size:32;default:resource" json:"category"` // resource, app, permission + IsDefault bool `gorm:"default:false" json:"is_default"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Steps []ApprovalWorkflowStep `gorm:"foreignKey:WorkflowID" json:"steps,omitempty"` +} + +func (ApprovalWorkflow) TableName() string { + return "sys_approval_workflows" +} + +// ApprovalWorkflowStep 审批流程步骤 +type ApprovalWorkflowStep struct { + ID uint `gorm:"primarykey" json:"id"` + WorkflowID uint `json:"workflow_id"` + StepNumber int `gorm:"not null" json:"step_number"` + Name string `gorm:"size:128;not null" json:"name"` + StepType string `gorm:"size:32;not null" json:"step_type"` // submit, audit, execute + ApproverIDs string `gorm:"type:text" json:"approver_ids"` // JSON array of user IDs + IsRequired bool `gorm:"default:true" json:"is_required"` + TimeoutHours int `gorm:"default:24" json:"timeout_hours"` + AutoApprove bool `gorm:"default:false" json:"auto_approve_on_timeout"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (ApprovalWorkflowStep) TableName() string { + return "sys_approval_workflow_steps" +} + +// CreateWorkflowRequest 创建审批流程请求 +type CreateWorkflowRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Category string `json:"category"` + Steps []CreateWorkflowStepReq `json:"steps"` +} + +type CreateWorkflowStepReq struct { + StepNumber int `json:"step_number"` + Name string `json:"name" binding:"required"` + StepType string `json:"step_type" binding:"required,oneof=submit audit execute"` + ApproverIDs string `json:"approver_ids"` + IsRequired bool `json:"is_required"` + TimeoutHours int `json:"timeout_hours"` + AutoApproveTimeout bool `json:"auto_approve_on_timeout"` +} + +// UpdateWorkflowRequest 更新审批流程请求 +type UpdateWorkflowRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Category *string `json:"category,omitempty"` + IsActive *bool `json:"is_active,omitempty"` + IsDefault *bool `json:"is_default,omitempty"` + Steps []CreateWorkflowStepReq `json:"steps,omitempty"` +} diff --git a/backend/model/user.go b/backend/model/user.go index fa421be..d2b6934 100644 --- a/backend/model/user.go +++ b/backend/model/user.go @@ -22,7 +22,7 @@ type User struct { UpdatedAt time.Time `json:"updated_at"` // 关联 - Roles []Role `gorm:"many2many:user_roles;" json:"roles,omitempty"` + Roles []Role `gorm:"many2many:iam_user_roles;" json:"roles,omitempty"` } // TableName 表名 @@ -37,6 +37,7 @@ type Role struct { Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` Description string `gorm:"size:255" json:"description"` Status int `gorm:"default:1" json:"status"` + IsSystem bool `gorm:"default:false" json:"is_system"` // 系统默认角色,禁止删除 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 9793873..9b999d9 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -134,6 +134,8 @@ func AutoMigrate(db *gorm.DB) error { &model.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li &model.AlertSilence{}, // 告警静默 // Logan.Li &model.HealthProbe{}, // 健康探针配置 // Logan.Li + &model.ApprovalWorkflow{}, // 审批流程定义 // Logan.Li + &model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li } var lastErr error diff --git a/backend/router/routes.go b/backend/router/routes.go index f256c9f..2641940 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -143,6 +143,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c user := authorized.Group("/user") { user.GET("/profile", authHandler.GetProfile) + user.GET("/permissions", authHandler.GetMyPermissions) user.PUT("/profile", authHandler.UpdateProfile) user.POST("/change-password", authHandler.ChangePassword) user.GET("/roles", authHandler.ListRoles) @@ -680,6 +681,19 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c approvals.POST("/processes/:id/reject", approvalHandler.RejectProcess) } + // 审批流程配置(工作流定义) + workflowHandler := controller.NewWorkflowHandler(db) + workflows := authorized.Group("/approval-workflows") + { + workflows.GET("", workflowHandler.ListWorkflows) + workflows.POST("", workflowHandler.CreateWorkflow) + workflows.GET("/:id", workflowHandler.GetWorkflow) + workflows.PUT("/:id", workflowHandler.UpdateWorkflow) + workflows.DELETE("/:id", workflowHandler.DeleteWorkflow) + workflows.PUT("/:id/default", workflowHandler.SetDefaultWorkflow) + workflows.GET("/:id/steps", workflowHandler.GetWorkflowSteps) + } + // 导航路由 navHandler := controller.NewNavigationHandler(db) navigations := authorized.Group("/navigations") diff --git a/backend/seed/seed.go b/backend/seed/seed.go index 8e85b63..77e4de3 100644 --- a/backend/seed/seed.go +++ b/backend/seed/seed.go @@ -82,7 +82,7 @@ func SeedData(db *gorm.DB) { {"key": "purpose", "label": "用途说明", "type": "textarea", "required": true} ] }`, - FlowConfig: `[{"name":"直接主管","approver_role":"team_lead"},{"name":"运维负责人","approver_role":"ops_manager"}]`, + FlowConfig: `[{"name":"直接主管","approver_role":"team_lead","step_type":"audit"},{"name":"运维负责人","approver_role":"ops_manager","step_type":"audit"},{"name":"运维执行","approver_role":"ops_manager","step_type":"execute"}]`, Enabled: true, }, { @@ -106,7 +106,7 @@ func SeedData(db *gorm.DB) { {"key": "remark", "label": "备注", "type": "textarea", "required": false} ] }`, - FlowConfig: `[{"name":"技术负责人","approver_role":"tech_lead"},{"name":"运维负责人","approver_role":"ops_manager"}]`, + FlowConfig: `[{"name":"技术负责人","approver_role":"tech_lead","step_type":"audit"},{"name":"运维负责人","approver_role":"ops_manager","step_type":"audit"},{"name":"部署执行","approver_role":"ops_manager","step_type":"execute"}]`, Enabled: true, }, { @@ -125,7 +125,7 @@ func SeedData(db *gorm.DB) { {"key": "dept", "label": "所属部门", "type": "text", "placeholder": "如 基础架构部", "required": true} ] }`, - FlowConfig: `[{"name":"直接主管","approver_role":"team_lead"},{"name":"安全负责人","approver_role":"security_lead"}]`, + FlowConfig: `[{"name":"直接主管","approver_role":"team_lead","step_type":"audit"},{"name":"安全负责人","approver_role":"security_lead","step_type":"audit"},{"name":"权限开通","approver_role":"ops_manager","step_type":"execute"}]`, Enabled: true, }, } @@ -182,6 +182,11 @@ func SeedData(db *gorm.DB) { {Name: "LDAP配置", Code: "system:ldap", Type: "menu", Sort: 83, Status: 1}, // 仪表盘 {Name: "仪表盘", Code: "dashboard:view", Type: "menu", Sort: 90, Status: 1}, + // 安全审计 + {Name: "终端审计", Code: "audit:terminal", Type: "menu", Sort: 91, Status: 1}, + {Name: "操作审计", Code: "audit:operations", Type: "menu", Sort: 92, Status: 1}, + {Name: "登录日志", Code: "audit:login-logs", Type: "menu", Sort: 93, Status: 1}, + {Name: "会话阻断", Code: "audit:kill-session", Type: "button", Sort: 94, Status: 1}, } db.Create(&perms) zap.L().Info("seeded default permissions") @@ -192,13 +197,16 @@ func SeedData(db *gorm.DB) { db.Model(&model.Role{}).Count(&roleCount) if roleCount == 0 { roles := []model.Role{ - {Name: "管理员", Code: "admin", Description: "系统管理员,拥有全部权限", Status: 1}, - {Name: "运维工程师", Code: "ops", Description: "日常运维管理", Status: 1}, - {Name: "开发工程师", Code: "developer", Description: "开发人员,只读权限", Status: 1}, - {Name: "只读用户", Code: "viewer", Description: "仅查看权限", Status: 1}, + {Name: "超级管理员", Code: "super_admin", Description: "系统最高权限,管理所有资源和配置", Status: 1, IsSystem: true}, + {Name: "Kubernetes管理员", Code: "k8s_admin", Description: "管理K8s集群、节点、工作负载和存储", Status: 1, IsSystem: true}, + {Name: "资源管理员", Code: "resource_admin", Description: "管理服务器、云账号、域名和云资源", Status: 1, IsSystem: true}, + {Name: "监控管理员", Code: "monitor_admin", Description: "管理告警、监控指标和采集Agent", Status: 1, IsSystem: true}, + {Name: "安全审计员", Code: "security_auditor", Description: "审计终端会话、操作日志和登录记录", Status: 1, IsSystem: true}, + {Name: "发布管理员", Code: "release_admin", Description: "管理流水线、应用发布和审批流程", Status: 1, IsSystem: true}, + {Name: "只读用户", Code: "viewer", Description: "仅查看仪表盘和资源概览,无操作权限", Status: 1, IsSystem: true}, } db.Create(&roles) - zap.L().Info("seeded default roles") + zap.L().Info("seeded 7 system default roles") // 为各角色分配权限 var allPerms []model.Permission @@ -206,37 +214,71 @@ func SeedData(db *gorm.DB) { for _, role := range roles { var permIds []uint switch role.Code { - case "admin": + case "super_admin": for _, p := range allPerms { permIds = append(permIds, p.ID) } - case "ops": + case "k8s_admin": + k8sPrefixes := []string{"k8s:", "server:webshell", "dashboard:view", "deploy:"} for _, p := range allPerms { - if !strings.HasPrefix(p.Code, "system:") { - permIds = append(permIds, p.ID) + for _, pre := range k8sPrefixes { + if strings.HasPrefix(p.Code, pre) { + permIds = append(permIds, p.ID) + break + } } } - case "developer": - devPerms := map[string]bool{ - "dashboard:view": true, "server:view": true, "cloud:account": true, - "cloud:domain": true, "cloud:billing": true, - "k8s:cluster": true, "k8s:nodes": true, "k8s:workloads": true, "k8s:topology": true, - "monitor:alert": true, "deploy:pipeline": true, "deploy:release": true, - "approval:apply": true, "aiops:assistant": true, "aiops:analysis": true, "aiops:forecast": true, - } + case "resource_admin": + resPrefixes := []string{"server:", "cloud:", "dashboard:view", "k8s:topology"} for _, p := range allPerms { - if devPerms[p.Code] { - permIds = append(permIds, p.ID) + for _, pre := range resPrefixes { + if strings.HasPrefix(p.Code, pre) { + permIds = append(permIds, p.ID) + break + } + } + } + case "monitor_admin": + monPrefixes := []string{"monitor:", "agent:", "dashboard:view", "server:view"} + for _, p := range allPerms { + for _, pre := range monPrefixes { + if strings.HasPrefix(p.Code, pre) { + permIds = append(permIds, p.ID) + break + } + } + } + case "security_auditor": + auditPrefixes := []string{"audit:", "dashboard:view", "server:view", "system:audit"} + for _, p := range allPerms { + for _, pre := range auditPrefixes { + if strings.HasPrefix(p.Code, pre) { + permIds = append(permIds, p.ID) + break + } + } + } + case "release_admin": + relPrefixes := []string{"deploy:", "approval:", "dashboard:view", "server:view", "k8s:cluster", "k8s:workloads"} + for _, p := range allPerms { + for _, pre := range relPrefixes { + if strings.HasPrefix(p.Code, pre) { + permIds = append(permIds, p.ID) + break + } } } case "viewer": for _, p := range allPerms { - if p.Code == "dashboard:view" || p.Code == "server:view" || p.Code == "monitor:alert" { + if p.Code == "dashboard:view" || p.Code == "server:view" || p.Code == "monitor:alert" || + p.Code == "k8s:cluster" || p.Code == "k8s:topology" || p.Code == "cloud:billing" || + p.Code == "deploy:pipeline" { permIds = append(permIds, p.ID) } } } if len(permIds) > 0 { + permIds = dedupeUint(permIds) var rolePerms []model.Permission db.Where("id IN ?", permIds).Find(&rolePerms) db.Model(&role).Association("Permissions").Append(rolePerms) @@ -650,3 +692,16 @@ func SeedData(db *gorm.DB) { ti := toolinstaller.New(db) ti.SeedTools() } + +// dedupeUint 去重 uint 切片 +func dedupeUint(slice []uint) []uint { + seen := make(map[uint]bool) + result := make([]uint, 0, len(slice)) + for _, v := range slice { + if !seen[v] { + seen[v] = true + result = append(result, v) + } + } + return result +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8efba95..4783582 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,7 @@ const ApprovalFlowManager = lazy(() => import('@/components/approval/ApprovalFlo const ApprovalResourcePage = lazy(() => import('@/pages/ApprovalResourcePage').then(m => ({ default: m.ApprovalResourcePage }))); const ApprovalAppPage = lazy(() => import('@/pages/ApprovalAppPage').then(m => ({ default: m.ApprovalAppPage }))); const ApprovalPermissionPage = lazy(() => import('@/pages/ApprovalPermissionPage').then(m => ({ default: m.ApprovalPermissionPage }))); +const ApprovalWorkflowPage = lazy(() => import('@/pages/ApprovalWorkflowPage').then(m => ({ default: m.default }))); const ScriptManager = lazy(() => import('@/components/automation/ScriptManager').then(m => ({ default: m.ScriptManager }))); const SystemManager = lazy(() => import('@/components/system/SystemManager').then(m => ({ default: m.SystemManager }))); const AuditPage = lazy(() => import('@/pages/AuditPage').then(m => ({ default: m.AuditPage }))); @@ -199,6 +200,7 @@ function App() { } /> } /> } /> + } /> {/* Fallback */} } /> diff --git a/frontend/src/components/auth/Login.tsx b/frontend/src/components/auth/Login.tsx index bd880be..edcc876 100644 --- a/frontend/src/components/auth/Login.tsx +++ b/frontend/src/components/auth/Login.tsx @@ -85,8 +85,8 @@ export function Login() { setError(''); setLoading(true); try { - const result = await userApi.login(username, password) as { user: any; token: string }; - useUserStore.getState().login(result.user, result.token); + const result = await userApi.login(username, password) as { user: any; token: string; permissions?: string[] }; + useUserStore.getState().login(result.user, result.token, result.permissions ?? null); localStorage.setItem('token', result.token); navigate('/', { replace: true }); } catch (err: any) { diff --git a/frontend/src/components/layout/Breadcrumb.tsx b/frontend/src/components/layout/Breadcrumb.tsx index b8d55e1..e244ec6 100644 --- a/frontend/src/components/layout/Breadcrumb.tsx +++ b/frontend/src/components/layout/Breadcrumb.tsx @@ -51,6 +51,7 @@ const ROUTE_NAME_MAP: Record = { 'approval-resource': '资源申请', 'approval-app': '应用申请', 'approval-permission': '权限审批', + 'approval-workflow': '审批流程', 'scripts': '脚本库', 'ansible': 'Ansible', 'ai-assistant': '智能助手', diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx index 3fcaf85..a714825 100644 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/components/layout/MainLayout.tsx @@ -1,9 +1,11 @@ +import { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; import { cn } from '@/lib/utils'; -import { useThemeStore } from '@/store'; +import { useThemeStore, useUserStore } from '@/store'; import { Sidebar } from './Sidebar'; import { Header } from './Header'; import { Breadcrumb } from './Breadcrumb'; +import { userApi } from '@/services/api'; /** * MainLayout - 优化后的主布局组件 @@ -14,9 +16,32 @@ import { Breadcrumb } from './Breadcrumb'; * 3. 添加 Breadcrumb 面包屑导航 * 4. 布局过渡动画平滑 * 5. 支持响应式布局 + * 6. F3: 页面刷新后自动拉取当前用户权限(RBAC 按钮级控制) */ export function MainLayout() { const { sidebarCollapsed } = useThemeStore(); + const token = useUserStore((s) => s.token); + const permissions = useUserStore((s) => s.permissions); + const setPermissions = useUserStore((s) => s.setPermissions); + + // 页面刷新后 zustand persist 会恢复 token/user,但 permissions 可能丢失或过期 + // 在 MainLayout 挂载时如果 permissions 为 null 则拉取一次 + useEffect(() => { + if (!token || permissions !== null) return; + let cancelled = false; + userApi.getMyPermissions() + .then((data: any) => { + if (cancelled) return; + const perms: string[] = data?.permissions ?? []; + setPermissions(Array.isArray(perms) ? perms : []); + }) + .catch(() => { + // 拉取失败不阻塞页面,权限保持 null(usePermission 默认放行) + if (!cancelled) setPermissions([]); + }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); return (
{ @@ -329,6 +334,20 @@ export function Sidebar() { return () => window.removeEventListener('toggle-sidebar' as any, handler); }, [toggleSidebar]); + // F3: 按权限过滤菜单(递归) + // 父级菜单:自身权限通过 且 至少有一个子菜单可见 + const filterMenu = (items: MenuItem[]): MenuItem[] => { + return items + .filter((item) => !item.permission || hasPermission(item.permission)) + .map((item) => { + if (!item.children) return item; + const visibleChildren = filterMenu(item.children); + return { ...item, children: visibleChildren }; + }) + .filter((item) => !item.children || item.children.length > 0); + }; + const visibleMenuItems = filterMenu(menuItems); + return (
@@ -464,6 +470,7 @@ export function ServerManager() { {selectedServers.length > 0 && (
已选择 {selectedServers.length} 项 + {hasPermission('server:delete') && ( + )}
)} diff --git a/frontend/src/components/server/ServerTableRow.tsx b/frontend/src/components/server/ServerTableRow.tsx index 9476396..61c164f 100644 --- a/frontend/src/components/server/ServerTableRow.tsx +++ b/frontend/src/components/server/ServerTableRow.tsx @@ -14,6 +14,7 @@ import { import { TableCell, TableRow } from '@/components/ui/table'; import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen } from 'lucide-react'; import { getStatusBadge, getTags, getUsageColor } from './serverHelpers'; +import { usePermission } from '@/hooks/usePermission'; interface ServerTableRowProps { server: any; @@ -34,6 +35,7 @@ export function ServerTableRow({ onOpenFiles, onDelete, }: ServerTableRowProps) { + const { hasPermission } = usePermission(); const cpuUsage = Number(server.cpu_usage || 0); const memoryUsage = Number(server.memory_usage || 0); const tags = getTags(server); @@ -147,9 +149,12 @@ export function ServerTableRow({
+ {hasPermission('server:webshell') && ( + )} + {(hasPermission('server:update') || hasPermission('server:delete') || hasPermission('server:webshell')) && (
diff --git a/frontend/src/components/system/RoleFormDialog.tsx b/frontend/src/components/system/RoleFormDialog.tsx index d5046e0..db07dd3 100644 --- a/frontend/src/components/system/RoleFormDialog.tsx +++ b/frontend/src/components/system/RoleFormDialog.tsx @@ -4,12 +4,7 @@ import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Shield } from 'lucide-react'; @@ -38,37 +33,69 @@ interface RoleFormDialogProps { permissions: PermissionOption[]; } +// 权限按模块分组 +const MODULE_GROUPS: { key: string; label: string; prefixes: string[] }[] = [ + { key: 'dashboard', label: '📊 仪表盘', prefixes: ['dashboard:'] }, + { key: 'server', label: '🖥️ 资产管理', prefixes: ['server:'] }, + { key: 'cloud', label: '☁️ 云资源', prefixes: ['cloud:'] }, + { key: 'k8s', label: '☸️ Kubernetes', prefixes: ['k8s:'] }, + { key: 'deploy', label: '🚀 应用发布', prefixes: ['deploy:'] }, + { key: 'monitor', label: '📡 监控告警', prefixes: ['monitor:', 'agent:'] }, + { key: 'auto', label: '⚙️ 自动化运维', prefixes: ['auto:'] }, + { key: 'aiops', label: '🤖 AIOps智能', prefixes: ['aiops:'] }, + { key: 'approval', label: '✅ 资源申请', prefixes: ['approval:'] }, + { key: 'audit', label: '🔍 安全审计', prefixes: ['audit:'] }, + { key: 'system', label: '🔧 系统管理', prefixes: ['system:'] }, +]; + +function groupPermissions(perms: PermissionOption[]): { key: string; label: string; items: PermissionOption[] }[] { + const grouped = MODULE_GROUPS.map(g => ({ + key: g.key, + label: g.label, + items: perms.filter(p => g.prefixes.some(pre => p.code.startsWith(pre))), + })).filter(g => g.items.length > 0); + + // 未分类的权限 + const classified = new Set(grouped.flatMap(g => g.items.map(p => p.id))); + const uncategorized = perms.filter(p => !classified.has(p.id)); + if (uncategorized.length > 0) { + grouped.push({ key: 'other', label: '📌 其他', items: uncategorized }); + } + + return grouped; +} + export function RoleFormDialog({ - open, - onOpenChange, - editingRole, - form, - setForm, - saving, - onSubmit, - permissions, + open, onOpenChange, editingRole, form, setForm, saving, onSubmit, permissions, }: RoleFormDialogProps) { const togglePermission = (id: number, checked: boolean) => { setForm((prev) => { const set = new Set(prev.permission_ids); - if (checked) { - set.add(id); - } else { - set.delete(id); - } + checked ? set.add(id) : set.delete(id); return { ...prev, permission_ids: Array.from(set) }; }); }; + // 全选/取消某个分组 + const toggleGroup = (items: PermissionOption[], checked: boolean) => { + setForm((prev) => { + const set = new Set(prev.permission_ids); + items.forEach(p => checked ? set.add(p.id) : set.delete(p.id)); + return { ...prev, permission_ids: Array.from(set) }; + }); + }; + + const grouped = groupPermissions(permissions); + return ( - + - + {editingRole ? '编辑角色' : '新建角色'} - + {editingRole ? '修改角色信息与权限' : '创建新的系统角色并分配权限'} @@ -76,68 +103,95 @@ export function RoleFormDialog({
- + setForm({ ...form, name: e.target.value })} - className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" + className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]" placeholder="如:运维工程师" />
- + setForm({ ...form, code: e.target.value })} disabled={!!editingRole} - className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500 disabled:opacity-60" + className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] disabled:opacity-60" placeholder="如:ops_engineer" />
- +