feat(rbac): F3 前端 RBAC 按钮级控制

后端:
- 新增 GET /user/permissions 端点返回当前用户权限码
- Login 响应附带 permissions 数组
- collectUserPermissionCodes 用 raw SQL JOIN 避免 GORM many2many 表名问题
- admin 用户返回 ['*'] 通配符

前端:
- usePermission hook (hasPermission/hasAnyPermission/hasAllPermissions)
- <HasPermission> 组件 (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 零错误
This commit is contained in:
attacker committed 2026-06-25 22:51:25 +08:00
1 parent 77c234b060
commit be2a377f01
30 files changed
+2328 -462

No files matched your search

+1 -1
View File
@@ -187,7 +187,7 @@
|----|:----:| |----|:----:|
| JWT 认证 | ✅ | | JWT 认证 | ✅ |
| RBAC 角色权限(后端) | ✅ | | RBAC 角色权限(后端) | ✅ |
| RBAC 前端按钮级控制 | ❌ 缺失 | | RBAC 前端按钮级控制 | ✅ `usePermission` hook + `<HasPermission>` + Sidebar 过滤 |
| SQL 注入防护 | ✅ 全参数化 | | SQL 注入防护 | ✅ 全参数化 |
| XSS 防护 | ✅ React 默认 | | XSS 防护 | ✅ React 默认 |
| HTTPS | ⚠️ 生产未强制 | | HTTPS | ⚠️ 生产未强制 |
+12 -8
View File
@@ -125,19 +125,23 @@ services/api.ts (1324L)
- [x] 导出限制 10000 条防内存溢出 - [x] 导出限制 10000 条防内存溢出
- [x] JSON 格式向后兼容(format=json) - [x] JSON 格式向后兼容(format=json)
### F3: 前端 RBAC 按钮级控制 [pending] ### F3: 前端 RBAC 按钮级控制 [done]
**问题**: 后端 RBAC 完善,但前端无按钮级权限控制 **问题**: 后端 RBAC 完善,但前端无按钮级权限控制
**方案**: **方案**:
- 封装 `<HasPermission permission="xxx">` 组件 - 后端新增 `GET /user/permissions` 端点返回当前用户权限码;Login 响应附带 `permissions` 数组
- Sidebar 菜单按角色过滤 - 封装 `<HasPermission permission="xxx">` 组件 + `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** | | Q3 | api.ts 拆分 | P0 | **done** |
| F1 | 告警静默规则 | P1 | **done** | | F1 | 告警静默规则 | P1 | **done** |
| F2 | 审计日志导出 | P1 | **done** | | F2 | 审计日志导出 | P1 | **done** |
| F3 | 前端 RBAC 控制 | P1 | pending | | F3 | 前端 RBAC 控制 | P1 | **done** |
| A1 | K8s 路由统一 | P2 | pending | | A1 | K8s 路由统一 | P2 | pending |
| A2 | 告警引擎分布式 | P2 | pending | | A2 | 告警引擎分布式 | P2 | pending |
| A3 | WebSocket 管理 | P2 | pending | | A3 | WebSocket 管理 | P2 | pending |
+8 -3
View File
@@ -324,9 +324,14 @@ curl -s -X POST "http://127.0.0.1:8080/api/v1/servers" \
``` ```
### 通过标准 ### 通过标准
- [ ] U1-U2: 创建角色和用户成功 - [x] U1-U2: admin 登录返回 `permissions: ["*"]`,`GET /user/permissions` 返回 `is_admin=true`
- [ ] U4: 写接口返回 403 - [x] U3-U4: viewer 角色有 7 个只读权限码(dashboard:view/server:view/monitor:alert/k8s:cluster/k8s:topology/cloud:billing/deploy:pipeline)
- [ ] U5: 前端按钮/菜单按权限显示 - [x] U5: viewer 用户登录返回受限权限数组(无 server:create/server:delete)
- [x] U6: `GET /user/permissions` 返回正确权限码(raw SQL JOIN 查询)
- [x] U7: viewer 可访问只读端点
- [x] 前端: `usePermission` hook + `<HasPermission>` 组件 + Sidebar 菜单过滤 + ServerManager/SystemManager 按钮显隐
- [x] 前端: `npm run build` 零错误
- [x] 回归: 10/10 核心端点通过
--- ---
+193 -57
View File
@@ -1,26 +1,38 @@
package controller package controller
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"time"
"ezops/model" "ezops/model"
"github.com/gin-gonic/gin" "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 // ListProcesses 获取审批流程列表 // Logan.Li
func (h *ApprovalHandler) ListProcesses(c *gin.Context) { func (h *ApprovalHandler) ListProcesses(c *gin.Context) {
var query model.PageQuery var query model.PageQuery
c.ShouldBindQuery(&query) 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 != "" { if status := c.Query("status"); status != "" {
db = db.Where("status = ?", status) db = db.Where("status = ?", status)
} }
// 按模板类别过滤
if category := c.Query("category"); category != "" { if category := c.Query("category"); category != "" {
db = db.Joins("JOIN sys_approval_templates ON sys_approval_templates.id = approval_processes.template_id"). db = db.Joins("JOIN sys_approval_templates ON sys_approval_templates.id = approval_processes.template_id").
Where("sys_approval_templates.category = ?", category) Where("sys_approval_templates.category = ?", category)
@@ -30,10 +42,14 @@ func (h *ApprovalHandler) ListProcesses(c *gin.Context) {
db.Count(&total) db.Count(&total)
var processes []model.ApprovalProcess var processes []model.ApprovalProcess
db.Order("created_at DESC"). db.Order("created_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&processes)
Offset(query.GetOffset()).
Limit(query.GetLimit()). // Preload approver user for each step
Find(&processes) 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{ c.JSON(http.StatusOK, model.Response{
Code: 200, Code: 200,
@@ -50,7 +66,10 @@ func (h *ApprovalHandler) GetProcess(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) id, _ := strconv.Atoi(c.Param("id"))
var process model.ApprovalProcess 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{ c.JSON(http.StatusNotFound, model.Response{
Code: 404, Code: 404,
Message: "流程不存在", 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) { func (h *ApprovalHandler) CreateProcess(c *gin.Context) {
var req model.CreateApprovalProcessRequest var req model.CreateApprovalProcessRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -76,46 +95,119 @@ func (h *ApprovalHandler) CreateProcess(c *gin.Context) {
return return
} }
// 获取当前用户 ID userIDVal, _ := c.Get("userID")
userID, _ := c.Get("user_id") userID := toUint(userIDVal)
// 获取模板
var template model.ApprovalTemplate var template model.ApprovalTemplate
if err := h.db.First(&template, req.TemplateID).Error; err != nil { if err := h.db.First(&template, req.TemplateID).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{ c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "模板不存在"})
Code: 404,
Message: "模板不存在",
})
return 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{ process := model.ApprovalProcess{
TemplateID: req.TemplateID, TemplateID: req.TemplateID,
Title: req.Title, Title: req.Title,
Applicant: toUint(userID), Applicant: userID,
FormData: req.FormData, FormData: req.FormData,
Status: "pending", Status: "pending",
CurrentStep: 0, CurrentStep: 1,
TotalSteps: 1, // 简化处理 TotalSteps: totalSteps,
} }
if err := h.db.Create(&process).Error; err != nil { tx := h.db.Begin()
c.JSON(http.StatusInternalServerError, model.Response{ if err := tx.Create(&process).Error; err != nil {
Code: 500, tx.Rollback()
Message: "创建失败: " + err.Error(), c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败: " + err.Error()})
})
return 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{ c.JSON(http.StatusOK, model.Response{
Code: 200, Code: 200,
Message: "创建成功", Message: "申请已提交",
Data: process, 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 { func toUint(v interface{}) uint {
switch val := v.(type) { switch val := v.(type) {
case float64: case float64:
@@ -135,32 +227,63 @@ func (h *ApprovalHandler) ApproveProcess(c *gin.Context) {
var req model.ApproveRequest var req model.ApproveRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{ c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"})
Code: 400,
Message: "请求参数错误",
})
return return
} }
userIDVal, _ := c.Get("userID")
approverID := toUint(userIDVal)
var process model.ApprovalProcess var process model.ApprovalProcess
if err := h.db.First(&process, id).Error; err != nil { if err := h.db.First(&process, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{ c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "流程不存在"})
Code: 404,
Message: "流程不存在",
})
return return
} }
process.Status = "approved" if process.Status != "pending" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该申请已处理"})
if err := h.db.Save(&process).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{
Code: 500,
Message: "审批失败",
})
return return
} }
now := time.Now()
// 查找当前步骤
var currentStep model.ApprovalStep
if err := h.db.Where("process_id = ? AND step = ?", process.ID, process.CurrentStep).First(&currentStep).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(&currentStep).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{ c.JSON(http.StatusOK, model.Response{
Code: 200, Code: 200,
Message: "审批通过", Message: "审批通过",
@@ -174,33 +297,46 @@ func (h *ApprovalHandler) RejectProcess(c *gin.Context) {
var req model.ApproveRequest var req model.ApproveRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{ c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"})
Code: 400,
Message: "请求参数错误",
})
return return
} }
userIDVal, _ := c.Get("userID")
approverID := toUint(userIDVal)
var process model.ApprovalProcess var process model.ApprovalProcess
if err := h.db.First(&process, id).Error; err != nil { if err := h.db.First(&process, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{ c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "流程不存在"})
Code: 404,
Message: "流程不存在",
})
return return
} }
process.Status = "rejected" if process.Status != "pending" {
process.RejectReason = req.Comment c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该申请已处理"})
if err := h.db.Save(&process).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{
Code: 500,
Message: "拒绝失败",
})
return 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{ c.JSON(http.StatusOK, model.Response{
Code: 200, Code: 200,
Message: "已拒绝", Message: "已拒绝",
+318
View File
@@ -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})
}
+6
View File
@@ -143,6 +143,12 @@ func (h *UserHandler) DeleteRole(c *gin.Context) {
return return
} }
// 系统默认角色禁止删除
if role.IsSystem {
c.JSON(http.StatusForbidden, model.Response{Code: 403, Message: "系统默认角色不可删除"})
return
}
// 清空权限关联后再删除(避免 join 表残留) // 清空权限关联后再删除(避免 join 表残留)
h.db.Model(&role).Association("Permissions").Clear() h.db.Model(&role).Association("Permissions").Clear()
if err := h.db.Delete(&role).Error; err != nil { if err := h.db.Delete(&role).Error; err != nil {
+74 -4
View File
@@ -219,13 +219,17 @@ func (h *UserHandler) Login(c *gin.Context) {
h.recordLoginLog(c, user.Username, "success", "", "password") h.recordLoginLog(c, user.Username, "success", "", "password")
// 聚合用户权限码(前端 RBAC 按钮级控制)
permissions := collectUserPermissionCodes(h.db, user.ID, user.IsAdmin)
c.JSON(http.StatusOK, model.Response{ c.JSON(http.StatusOK, model.Response{
Code: 200, Code: 200,
Message: "登录成功", Message: "登录成功",
Data: model.LoginResponse{ Data: gin.H{
Token: token, "token": token,
ExpiresIn: h.cfg.JWT.ExpireHour * 3600, "expires_in": h.cfg.JWT.ExpireHour * 3600,
User: &user, "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 更新当前用户信息 // UpdateProfile 更新当前用户信息
// @Summary 更新当前用户信息 // @Summary 更新当前用户信息
// @Description 更新当前登录用户的信息 // @Description 更新当前登录用户的信息
+1
View File
@@ -57,6 +57,7 @@ type ApprovalStep struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
ProcessID uint `json:"process_id"` ProcessID uint `json:"process_id"`
Step int `json:"step"` Step int `json:"step"`
StepType string `gorm:"size:32;default:audit" json:"step_type"` // audit, execute
Approver uint `json:"approver"` Approver uint `json:"approver"`
Status string `gorm:"size:32;default:pending" json:"status"` // pending, approved, rejected Status string `gorm:"size:32;default:pending" json:"status"` // pending, approved, rejected
Comment string `gorm:"size:500" json:"comment"` Comment string `gorm:"size:500" json:"comment"`
+68
View File
@@ -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"`
}
+2 -1
View File
@@ -22,7 +22,7 @@ type User struct {
UpdatedAt time.Time `json:"updated_at"` 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 表名 // TableName 表名
@@ -37,6 +37,7 @@ type Role struct {
Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
Description string `gorm:"size:255" json:"description"` Description string `gorm:"size:255" json:"description"`
Status int `gorm:"default:1" json:"status"` Status int `gorm:"default:1" json:"status"`
IsSystem bool `gorm:"default:false" json:"is_system"` // 系统默认角色,禁止删除
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
+2
View File
@@ -134,6 +134,8 @@ func AutoMigrate(db *gorm.DB) error {
&model.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li &model.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li
&model.AlertSilence{}, // 告警静默 // Logan.Li &model.AlertSilence{}, // 告警静默 // Logan.Li
&model.HealthProbe{}, // 健康探针配置 // Logan.Li &model.HealthProbe{}, // 健康探针配置 // Logan.Li
&model.ApprovalWorkflow{}, // 审批流程定义 // Logan.Li
&model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li
} }
var lastErr error var lastErr error
+14
View File
@@ -143,6 +143,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
user := authorized.Group("/user") user := authorized.Group("/user")
{ {
user.GET("/profile", authHandler.GetProfile) user.GET("/profile", authHandler.GetProfile)
user.GET("/permissions", authHandler.GetMyPermissions)
user.PUT("/profile", authHandler.UpdateProfile) user.PUT("/profile", authHandler.UpdateProfile)
user.POST("/change-password", authHandler.ChangePassword) user.POST("/change-password", authHandler.ChangePassword)
user.GET("/roles", authHandler.ListRoles) 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) 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) navHandler := controller.NewNavigationHandler(db)
navigations := authorized.Group("/navigations") navigations := authorized.Group("/navigations")
+78 -23
View File
@@ -82,7 +82,7 @@ func SeedData(db *gorm.DB) {
{"key": "purpose", "label": "用途说明", "type": "textarea", "required": true} {"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, Enabled: true,
}, },
{ {
@@ -106,7 +106,7 @@ func SeedData(db *gorm.DB) {
{"key": "remark", "label": "备注", "type": "textarea", "required": false} {"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, Enabled: true,
}, },
{ {
@@ -125,7 +125,7 @@ func SeedData(db *gorm.DB) {
{"key": "dept", "label": "所属部门", "type": "text", "placeholder": "如 基础架构部", "required": true} {"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, Enabled: true,
}, },
} }
@@ -182,6 +182,11 @@ func SeedData(db *gorm.DB) {
{Name: "LDAP配置", Code: "system:ldap", Type: "menu", Sort: 83, Status: 1}, {Name: "LDAP配置", Code: "system:ldap", Type: "menu", Sort: 83, Status: 1},
// 仪表盘 // 仪表盘
{Name: "仪表盘", Code: "dashboard:view", Type: "menu", Sort: 90, 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) db.Create(&perms)
zap.L().Info("seeded default permissions") zap.L().Info("seeded default permissions")
@@ -192,13 +197,16 @@ func SeedData(db *gorm.DB) {
db.Model(&model.Role{}).Count(&roleCount) db.Model(&model.Role{}).Count(&roleCount)
if roleCount == 0 { if roleCount == 0 {
roles := []model.Role{ roles := []model.Role{
{Name: "管理员", Code: "admin", Description: "系统管理员,拥有全部权限", Status: 1}, {Name: "超级管理员", Code: "super_admin", Description: "系统最高权限,管理所有资源和配置", Status: 1, IsSystem: true},
{Name: "运维工程师", Code: "ops", Description: "日常运维管理", Status: 1}, {Name: "Kubernetes管理员", Code: "k8s_admin", Description: "管理K8s集群、节点、工作负载和存储", Status: 1, IsSystem: true},
{Name: "开发工程师", Code: "developer", Description: "开发人员,只读权限", Status: 1}, {Name: "资源管理员", Code: "resource_admin", Description: "管理服务器、云账号、域名和云资源", Status: 1, IsSystem: true},
{Name: "只读用户", Code: "viewer", Description: "仅查看权限", Status: 1}, {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) db.Create(&roles)
zap.L().Info("seeded default roles") zap.L().Info("seeded 7 system default roles")
// 为各角色分配权限 // 为各角色分配权限
var allPerms []model.Permission var allPerms []model.Permission
@@ -206,37 +214,71 @@ func SeedData(db *gorm.DB) {
for _, role := range roles { for _, role := range roles {
var permIds []uint var permIds []uint
switch role.Code { switch role.Code {
case "admin": case "super_admin":
for _, p := range allPerms { for _, p := range allPerms {
permIds = append(permIds, p.ID) permIds = append(permIds, p.ID)
} }
case "ops": case "k8s_admin":
k8sPrefixes := []string{"k8s:", "server:webshell", "dashboard:view", "deploy:"}
for _, p := range allPerms { for _, p := range allPerms {
if !strings.HasPrefix(p.Code, "system:") { for _, pre := range k8sPrefixes {
permIds = append(permIds, p.ID) if strings.HasPrefix(p.Code, pre) {
permIds = append(permIds, p.ID)
break
}
} }
} }
case "developer": case "resource_admin":
devPerms := map[string]bool{ resPrefixes := []string{"server:", "cloud:", "dashboard:view", "k8s:topology"}
"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,
}
for _, p := range allPerms { for _, p := range allPerms {
if devPerms[p.Code] { for _, pre := range resPrefixes {
permIds = append(permIds, p.ID) 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": case "viewer":
for _, p := range allPerms { 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) permIds = append(permIds, p.ID)
} }
} }
} }
if len(permIds) > 0 { if len(permIds) > 0 {
permIds = dedupeUint(permIds)
var rolePerms []model.Permission var rolePerms []model.Permission
db.Where("id IN ?", permIds).Find(&rolePerms) db.Where("id IN ?", permIds).Find(&rolePerms)
db.Model(&role).Association("Permissions").Append(rolePerms) db.Model(&role).Association("Permissions").Append(rolePerms)
@@ -650,3 +692,16 @@ func SeedData(db *gorm.DB) {
ti := toolinstaller.New(db) ti := toolinstaller.New(db)
ti.SeedTools() 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
}
+2
View File
@@ -35,6 +35,7 @@ const ApprovalFlowManager = lazy(() => import('@/components/approval/ApprovalFlo
const ApprovalResourcePage = lazy(() => import('@/pages/ApprovalResourcePage').then(m => ({ default: m.ApprovalResourcePage }))); const ApprovalResourcePage = lazy(() => import('@/pages/ApprovalResourcePage').then(m => ({ default: m.ApprovalResourcePage })));
const ApprovalAppPage = lazy(() => import('@/pages/ApprovalAppPage').then(m => ({ default: m.ApprovalAppPage }))); const ApprovalAppPage = lazy(() => import('@/pages/ApprovalAppPage').then(m => ({ default: m.ApprovalAppPage })));
const ApprovalPermissionPage = lazy(() => import('@/pages/ApprovalPermissionPage').then(m => ({ default: m.ApprovalPermissionPage }))); 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 ScriptManager = lazy(() => import('@/components/automation/ScriptManager').then(m => ({ default: m.ScriptManager })));
const SystemManager = lazy(() => import('@/components/system/SystemManager').then(m => ({ default: m.SystemManager }))); const SystemManager = lazy(() => import('@/components/system/SystemManager').then(m => ({ default: m.SystemManager })));
const AuditPage = lazy(() => import('@/pages/AuditPage').then(m => ({ default: m.AuditPage }))); const AuditPage = lazy(() => import('@/pages/AuditPage').then(m => ({ default: m.AuditPage })));
@@ -199,6 +200,7 @@ function App() {
<Route path="roles" element={<LazyPage><SystemManager /></LazyPage>} /> <Route path="roles" element={<LazyPage><SystemManager /></LazyPage>} />
<Route path="credentials" element={<LazyPage><CredentialManager /></LazyPage>} /> <Route path="credentials" element={<LazyPage><CredentialManager /></LazyPage>} />
<Route path="settings" element={<LazyPage><SystemManager /></LazyPage>} /> <Route path="settings" element={<LazyPage><SystemManager /></LazyPage>} />
<Route path="approval-workflow" element={<LazyPage><ApprovalWorkflowPage /></LazyPage>} />
{/* Fallback */} {/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+2 -2
View File
@@ -85,8 +85,8 @@ export function Login() {
setError(''); setError('');
setLoading(true); setLoading(true);
try { try {
const result = await userApi.login(username, password) as { user: any; token: string }; const result = await userApi.login(username, password) as { user: any; token: string; permissions?: string[] };
useUserStore.getState().login(result.user, result.token); useUserStore.getState().login(result.user, result.token, result.permissions ?? null);
localStorage.setItem('token', result.token); localStorage.setItem('token', result.token);
navigate('/', { replace: true }); navigate('/', { replace: true });
} catch (err: any) { } catch (err: any) {
@@ -51,6 +51,7 @@ const ROUTE_NAME_MAP: Record<string, string> = {
'approval-resource': '资源申请', 'approval-resource': '资源申请',
'approval-app': '应用申请', 'approval-app': '应用申请',
'approval-permission': '权限审批', 'approval-permission': '权限审批',
'approval-workflow': '审批流程',
'scripts': '脚本库', 'scripts': '脚本库',
'ansible': 'Ansible', 'ansible': 'Ansible',
'ai-assistant': '智能助手', 'ai-assistant': '智能助手',
+26 -1
View File
@@ -1,9 +1,11 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useThemeStore } from '@/store'; import { useThemeStore, useUserStore } from '@/store';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { Header } from './Header'; import { Header } from './Header';
import { Breadcrumb } from './Breadcrumb'; import { Breadcrumb } from './Breadcrumb';
import { userApi } from '@/services/api';
/** /**
* MainLayout - 优化后的主布局组件 * MainLayout - 优化后的主布局组件
@@ -14,9 +16,32 @@ import { Breadcrumb } from './Breadcrumb';
* 3. 添加 Breadcrumb 面包屑导航 * 3. 添加 Breadcrumb 面包屑导航
* 4. 布局过渡动画平滑 * 4. 布局过渡动画平滑
* 5. 支持响应式布局 * 5. 支持响应式布局
* 6. F3: 页面刷新后自动拉取当前用户权限(RBAC 按钮级控制)
*/ */
export function MainLayout() { export function MainLayout() {
const { sidebarCollapsed } = useThemeStore(); 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 ( return (
<div <div
+80 -61
View File
@@ -1,7 +1,8 @@
import { useEffect} from 'react'; import { useEffect } from 'react';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useThemeStore, useMenuStore } from '@/store'; import { useThemeStore, useMenuStore } from '@/store';
import { usePermission } from '@/hooks/usePermission';
import { import {
LayoutDashboard, LayoutDashboard,
@@ -50,113 +51,116 @@ interface MenuItem {
icon: React.ElementType; icon: React.ElementType;
path?: string; path?: string;
children?: MenuItem[]; children?: MenuItem[];
/** RBAC 权限码 — 用户需拥有此权限才显示该菜单项;不填=所有人可见 */
permission?: string;
} }
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ id: 'dashboard', name: '仪表盘', icon: LayoutDashboard, path: '/' }, { id: 'dashboard', name: '仪表盘', icon: LayoutDashboard, path: '/', permission: 'dashboard:view' },
{ {
id: 'assets', name: '资产管理', icon: Server, id: 'assets', name: '资产管理', icon: Server, permission: 'server:view',
children: [ children: [
{ id: 'servers', name: '服务器管理', icon: Database, path: '/servers' }, { id: 'servers', name: '服务器管理', icon: Database, path: '/servers', permission: 'server:view' },
{ id: 'cloud-accounts', name: '云账号管理', icon: Key, path: '/cloud-accounts' }, { id: 'cloud-accounts', name: '云账号管理', icon: Key, path: '/cloud-accounts', permission: 'cloud:account' },
{ id: 'domains', name: '域名管理', icon: Globe, path: '/domains' }, { id: 'domains', name: '域名管理', icon: Globe, path: '/domains', permission: 'cloud:domain' },
{ id: 'regions', name: '区域管理', icon: MapPin, path: '/regions' }, { id: 'regions', name: '区域管理', icon: MapPin, path: '/regions', permission: 'cloud:account' },
{ id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms' }, { id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms', permission: 'cloud:account' },
{ id: 'cloud-billing', name: '云账单', icon: BarChart3, path: '/cloud-billing' }, { id: 'cloud-billing', name: '云账单', icon: BarChart3, path: '/cloud-billing', permission: 'cloud:billing' },
], ],
}, },
{ {
id: 'kubernetes', name: 'Kubernetes', icon: Cloud, id: 'kubernetes', name: 'Kubernetes', icon: Cloud, permission: 'k8s:cluster',
children: [ children: [
{ id: 'k8s-clusters', name: '集群列表', icon: Cloud, path: '/kubernetes' }, { id: 'k8s-clusters', name: '集群列表', icon: Cloud, path: '/kubernetes', permission: 'k8s:cluster' },
{ id: 'k8s-nodes', name: '节点管理', icon: Cpu, path: '/nodes' }, { id: 'k8s-nodes', name: '节点管理', icon: Cpu, path: '/nodes', permission: 'k8s:nodes' },
{ {
id: 'k8s-workload', name: '工作负载', icon: Boxes, id: 'k8s-workload', name: '工作负载', icon: Boxes, permission: 'k8s:workloads',
children: [ children: [
{ id: 'k8s-deployments', name: 'Deployments', icon: Boxes, path: '/workloads/deployments' }, { id: 'k8s-deployments', name: 'Deployments', icon: Boxes, path: '/workloads/deployments', permission: 'k8s:workloads' },
{ id: 'k8s-statefulsets', name: 'StatefulSets', icon: Boxes, path: '/workloads/statefulsets' }, { id: 'k8s-statefulsets', name: 'StatefulSets', icon: Boxes, path: '/workloads/statefulsets', permission: 'k8s:workloads' },
{ id: 'k8s-daemonsets', name: 'DaemonSets', icon: Boxes, path: '/workloads/daemonsets' }, { id: 'k8s-daemonsets', name: 'DaemonSets', icon: Boxes, path: '/workloads/daemonsets', permission: 'k8s:workloads' },
{ id: 'k8s-cronjobs', name: 'CronJobs', icon: Clock, path: '/workloads/cronjobs' }, { id: 'k8s-cronjobs', name: 'CronJobs', icon: Clock, path: '/workloads/cronjobs', permission: 'k8s:workloads' },
{ id: 'k8s-pods', name: 'Pods', icon: Container, path: '/workloads/pods' }, { id: 'k8s-pods', name: 'Pods', icon: Container, path: '/workloads/pods', permission: 'k8s:workloads' },
], ],
}, },
{ {
id: 'k8s-network', name: '网络', icon: Network, id: 'k8s-network', name: '网络', icon: Network, permission: 'k8s:workloads',
children: [ children: [
{ id: 'k8s-services', name: 'Services', icon: Network, path: '/workloads/services' }, { id: 'k8s-services', name: 'Services', icon: Network, path: '/workloads/services', permission: 'k8s:workloads' },
{ id: 'k8s-ingresses', name: 'Ingresses', icon: Globe, path: '/workloads/ingresses' }, { id: 'k8s-ingresses', name: 'Ingresses', icon: Globe, path: '/workloads/ingresses', permission: 'k8s:workloads' },
], ],
}, },
{ {
id: 'k8s-config', name: '配置中心', icon: Settings, id: 'k8s-config', name: '配置中心', icon: Settings, permission: 'k8s:workloads',
children: [ children: [
{ id: 'k8s-configmaps', name: 'ConfigMaps', icon: FileCode, path: '/workloads/configmaps' }, { id: 'k8s-configmaps', name: 'ConfigMaps', icon: FileCode, path: '/workloads/configmaps', permission: 'k8s:workloads' },
{ id: 'k8s-secrets', name: 'Secrets', icon: Shield, path: '/workloads/secrets' }, { id: 'k8s-secrets', name: 'Secrets', icon: Shield, path: '/workloads/secrets', permission: 'k8s:workloads' },
], ],
}, },
{ id: 'k8s-storage', name: '存储', icon: Database, path: '/workloads/pvcs' }, { id: 'k8s-storage', name: '存储', icon: Database, path: '/workloads/pvcs', permission: 'k8s:workloads' },
{ id: 'k8s-metrics', name: '资源用量', icon: Activity, path: '/workloads/metrics' }, { id: 'k8s-metrics', name: '资源用量', icon: Activity, path: '/workloads/metrics', permission: 'k8s:workloads' },
{ id: 'k8s-events', name: '事件', icon: Bell, path: '/workloads/events' }, { id: 'k8s-events', name: '事件', icon: Bell, path: '/workloads/events', permission: 'k8s:workloads' },
{ id: 'k8s-capacity', name: '容量分析', icon: BarChart3, path: '/workloads/capacity' }, { id: 'k8s-capacity', name: '容量分析', icon: BarChart3, path: '/workloads/capacity', permission: 'k8s:workloads' },
{ id: 'k8s-topology', name: '架构拓扑', icon: Network, path: '/topology' }, { id: 'k8s-topology', name: '架构拓扑', icon: Network, path: '/topology', permission: 'k8s:topology' },
], ],
}, },
{ {
id: 'deploy', name: '应用发布', icon: Rocket, id: 'deploy', name: '应用发布', icon: Rocket, permission: 'deploy:pipeline',
children: [ children: [
{ id: 'pipelines', name: '流水线', icon: GitBranch, path: '/pipelines' }, { id: 'pipelines', name: '流水线', icon: GitBranch, path: '/pipelines', permission: 'deploy:pipeline' },
{ id: 'deploy-credentials', name: '发布配置', icon: Key, path: '/deploy/configureTools' }, { id: 'deploy-credentials', name: '发布配置', icon: Key, path: '/deploy/configureTools', permission: 'deploy:release' },
{ id: 'deploy-rbac', name: '发布权限', icon: Shield, path: '/deploy/rbac' }, { id: 'deploy-rbac', name: '发布权限', icon: Shield, path: '/deploy/rbac', permission: 'deploy:release' },
], ],
}, },
{ {
id: 'monitor', name: '监控中心', icon: Bell, id: 'monitor', name: '监控中心', icon: Bell, permission: 'monitor:alert',
children: [ children: [
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards' }, { id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards', permission: 'monitor:alert' },
{ id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center' }, { id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center', permission: 'agent:monitor' },
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' }, { id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace', permission: 'monitor:alert' },
{ id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center' }, { id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center', permission: 'monitor:alert' },
{ id: 'datasources', name: '数据源', icon: Database, path: '/datasources' }, { id: 'datasources', name: '数据源', icon: Database, path: '/datasources', permission: 'monitor:alert' },
], ],
}, },
{ {
id: 'automation', name: '自动化运维', icon: PlayCircle, id: 'automation', name: '自动化运维', icon: PlayCircle, permission: 'auto:cron',
children: [ children: [
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' }, { id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs', permission: 'auto:cron' },
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' }, { id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts', permission: 'auto:script' },
{ id: 'ansible', name: '远程执行', icon: Terminal, path: '/ansible' }, { id: 'ansible', name: '远程执行', icon: Terminal, path: '/ansible', permission: 'auto:ansible' },
], ],
}, },
{ {
id: 'aiops', name: 'AIOps智能', icon: Brain, id: 'aiops', name: 'AIOps智能', icon: Brain, permission: 'aiops:assistant',
children: [ children: [
{ id: 'ai-assistant', name: '智能助手', icon: Bot, path: '/ai-assistant' }, { id: 'ai-assistant', name: '智能助手', icon: Bot, path: '/ai-assistant', permission: 'aiops:assistant' },
{ id: 'ai-alert-analyzer', name: '告警分析', icon: AlertTriangle, path: '/ai-alert-analyzer' }, { id: 'ai-alert-analyzer', name: '告警分析', icon: AlertTriangle, path: '/ai-alert-analyzer', permission: 'aiops:analysis' },
{ id: 'ai-predictive', name: '容量预测', icon: BarChart3, path: '/ai-predictive' }, { id: 'ai-predictive', name: '容量预测', icon: BarChart3, path: '/ai-predictive', permission: 'aiops:forecast' },
], ],
}, },
{ id: 'approval', name: '资源申请', icon: CheckSquare, { id: 'approval', name: '资源申请', icon: CheckSquare, permission: 'approval:apply',
children: [ children: [
{ id: 'approval-resource', name: '资源申请', icon: Server, path: '/approval/resource' }, { id: 'approval-resource', name: '资源申请', icon: Server, path: '/approval/resource', permission: 'approval:apply' },
{ id: 'approval-app', name: '应用申请', icon: Rocket, path: '/approval/app' }, { id: 'approval-app', name: '应用申请', icon: Rocket, path: '/approval/app', permission: 'approval:apply' },
{ id: 'approval-permission', name: '权限审批', icon: ShieldCheck, path: '/approval/permission' }, { id: 'approval-permission', name: '权限审批', icon: ShieldCheck, path: '/approval/permission', permission: 'approval:apply' },
], ],
}, },
{ {
id: 'audit', name: '安全审计', icon: FileSearch, id: 'audit', name: '安全审计', icon: FileSearch, permission: 'audit:terminal',
children: [ children: [
{ id: 'audit-terminal', name: '终端审计', icon: Terminal, path: '/audit/terminal' }, { id: 'audit-terminal', name: '终端审计', icon: Terminal, path: '/audit/terminal', permission: 'audit:terminal' },
{ id: 'audit-operations', name: '操作审计', icon: FileCode, path: '/audit/operations' }, { id: 'audit-operations', name: '操作审计', icon: FileCode, path: '/audit/operations', permission: 'audit:operations' },
{ id: 'audit-login', name: '登录日志', icon: LogIn, path: '/audit/login-logs' }, { id: 'audit-login', name: '登录日志', icon: LogIn, path: '/audit/login-logs', permission: 'audit:login-logs' },
], ],
}, },
{ {
id: 'system', name: '系统管理', icon: Settings, id: 'system', name: '系统管理', icon: Settings, permission: 'system:user',
children: [ children: [
{ id: 'users', name: '用户管理', icon: Users, path: '/users' }, { id: 'users', name: '用户管理', icon: Users, path: '/users', permission: 'system:user' },
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' }, { id: 'roles', name: '角色权限', icon: Shield, path: '/roles', permission: 'system:role' },
{ id: 'credentials', name: '凭证配置', icon: Shield, path: '/credentials' }, { id: 'credentials', name: '凭证配置', icon: Shield, path: '/credentials', permission: 'system:user' },
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' }, { id: 'approval-workflow', name: '审批流程', icon: GitBranch, path: '/approval-workflow', permission: 'system:role' },
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings', permission: 'system:user' },
], ],
}, },
]; ];
@@ -321,6 +325,7 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
*/ */
export function Sidebar() { export function Sidebar() {
const { sidebarCollapsed, toggleSidebar } = useThemeStore(); const { sidebarCollapsed, toggleSidebar } = useThemeStore();
const { hasPermission } = usePermission();
// Listen for keyboard shortcut // Listen for keyboard shortcut
useEffect(() => { useEffect(() => {
@@ -329,6 +334,20 @@ export function Sidebar() {
return () => window.removeEventListener('toggle-sidebar' as any, handler); return () => window.removeEventListener('toggle-sidebar' as any, handler);
}, [toggleSidebar]); }, [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 ( return (
<aside <aside
className={cn( className={cn(
@@ -365,7 +384,7 @@ export function Sidebar() {
{/* Menu Items */} {/* Menu Items */}
<ScrollArea className="flex-1 py-2 px-1.5"> <ScrollArea className="flex-1 py-2 px-1.5">
<nav className="space-y-0.5"> <nav className="space-y-0.5">
{menuItems.map((item) => ( {visibleMenuItems.map((item) => (
<MenuItemComponent key={item.id} item={item} /> <MenuItemComponent key={item.id} item={item} />
))} ))}
</nav> </nav>
@@ -0,0 +1,51 @@
/**
* HasPermission — 条件渲染子组件(基于 RBAC 权限码)
*
* 用法:
* <HasPermission permission="server:create">
* <Button>新增服务器</Button>
* </HasPermission>
*
* <HasPermission anyOf={['server:update', 'server:delete']}>
* <Button>操作</Button>
* </HasPermission>
*
* <HasPermission allOf={['server:update', 'server:webshell']}>
* <Button>运维操作</Button>
* </HasPermission>
*
* <HasPermission permission="server:delete" fallback={<span className="text-xs text-muted">无权限</span>}>
* <Button>删除</Button>
* </HasPermission>
*/
import type { ReactNode } from 'react';
import { usePermission } from '@/hooks/usePermission';
interface HasPermissionProps {
/** 单个权限码 */
permission?: string;
/** 任意一个即可(OR) */
anyOf?: string[];
/** 全部都要(AND) */
allOf?: string[];
/** 无权限时渲染的兜底内容 */
fallback?: ReactNode;
children: ReactNode;
}
export function HasPermission({
permission,
anyOf,
allOf,
fallback = null,
children,
}: HasPermissionProps) {
const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission();
let ok = true;
if (permission) ok = ok && hasPermission(permission);
if (anyOf && anyOf.length > 0) ok = ok && hasAnyPermission(anyOf);
if (allOf && allOf.length > 0) ok = ok && hasAllPermissions(allOf);
return <>{ok ? children : fallback}</>;
}
@@ -51,9 +51,11 @@ import { ExcelServerImport } from './ExcelServerImport';
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm'; import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
import { getTags } from './serverHelpers'; import { getTags } from './serverHelpers';
import { ServerTableRow } from './ServerTableRow'; import { ServerTableRow } from './ServerTableRow';
import { usePermission } from '@/hooks/usePermission';
export function ServerManager() { export function ServerManager() {
const confirm = useConfirm(); const confirm = useConfirm();
const { hasPermission } = usePermission();
const [servers, setServers] = useState<any[]>([]); const [servers, setServers] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedServers, setSelectedServers] = useState<string[]>([]); const [selectedServers, setSelectedServers] = useState<string[]>([]);
@@ -392,6 +394,7 @@ export function ServerManager() {
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
导出 导出
</Button> </Button>
{hasPermission('server:create') && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"> <Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
@@ -420,13 +423,16 @@ export function ServerManager() {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button )}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white" {hasPermission('server:create') && (
onClick={openCreateForm} <Button
> className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
<Plus className="h-4 w-4 mr-2" /> onClick={openCreateForm}
添加服务器 >
</Button> <Plus className="h-4 w-4 mr-2" />
添加服务器
</Button>
)}
</div> </div>
</div> </div>
@@ -464,6 +470,7 @@ export function ServerManager() {
{selectedServers.length > 0 && ( {selectedServers.length > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedServers.length} 项</span> <span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedServers.length} 项</span>
{hasPermission('server:delete') && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -473,6 +480,7 @@ export function ServerManager() {
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
批量删除 批量删除
</Button> </Button>
)}
</div> </div>
)} )}
</div> </div>
@@ -14,6 +14,7 @@ import {
import { TableCell, TableRow } from '@/components/ui/table'; import { TableCell, TableRow } from '@/components/ui/table';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen } from 'lucide-react'; import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen } from 'lucide-react';
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers'; import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
import { usePermission } from '@/hooks/usePermission';
interface ServerTableRowProps { interface ServerTableRowProps {
server: any; server: any;
@@ -34,6 +35,7 @@ export function ServerTableRow({
onOpenFiles, onOpenFiles,
onDelete, onDelete,
}: ServerTableRowProps) { }: ServerTableRowProps) {
const { hasPermission } = usePermission();
const cpuUsage = Number(server.cpu_usage || 0); const cpuUsage = Number(server.cpu_usage || 0);
const memoryUsage = Number(server.memory_usage || 0); const memoryUsage = Number(server.memory_usage || 0);
const tags = getTags(server); const tags = getTags(server);
@@ -147,9 +149,12 @@ export function ServerTableRow({
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{hasPermission('server:webshell') && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)} title="终端登录"> <Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)} title="终端登录">
<Terminal className="h-4 w-4" /> <Terminal className="h-4 w-4" />
</Button> </Button>
)}
{(hasPermission('server:update') || hasPermission('server:delete') || hasPermission('server:webshell')) && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"> <Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
@@ -159,6 +164,7 @@ export function ServerTableRow({
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"> <DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel> <DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" /> <DropdownMenuSeparator className="bg-[var(--dg-border)]" />
{hasPermission('server:update') && (
<DropdownMenuItem <DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer" className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenEdit(server)} onClick={() => onOpenEdit(server)}
@@ -166,6 +172,8 @@ export function ServerTableRow({
<Edit className="h-4 w-4 mr-2" /> <Edit className="h-4 w-4 mr-2" />
编辑 编辑
</DropdownMenuItem> </DropdownMenuItem>
)}
{hasPermission('server:webshell') && (
<DropdownMenuItem <DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer" className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenTerminal(server)} onClick={() => onOpenTerminal(server)}
@@ -173,6 +181,8 @@ export function ServerTableRow({
<Terminal className="h-4 w-4 mr-2" /> <Terminal className="h-4 w-4 mr-2" />
终端 终端
</DropdownMenuItem> </DropdownMenuItem>
)}
{hasPermission('server:update') && (
<DropdownMenuItem <DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer" className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenFiles?.(server)} onClick={() => onOpenFiles?.(server)}
@@ -180,6 +190,9 @@ export function ServerTableRow({
<FolderOpen className="h-4 w-4 mr-2" /> <FolderOpen className="h-4 w-4 mr-2" />
文件 文件
</DropdownMenuItem> </DropdownMenuItem>
)}
{hasPermission('server:delete') && (
<>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" /> <DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem <DropdownMenuItem
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer" className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
@@ -188,8 +201,11 @@ export function ServerTableRow({
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
删除 删除
</DropdownMenuItem> </DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
+103 -49
View File
@@ -4,12 +4,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import {
Dialog, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Shield } from 'lucide-react'; import { Shield } from 'lucide-react';
@@ -38,37 +33,69 @@ interface RoleFormDialogProps {
permissions: PermissionOption[]; 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({ export function RoleFormDialog({
open, open, onOpenChange, editingRole, form, setForm, saving, onSubmit, permissions,
onOpenChange,
editingRole,
form,
setForm,
saving,
onSubmit,
permissions,
}: RoleFormDialogProps) { }: RoleFormDialogProps) {
const togglePermission = (id: number, checked: boolean) => { const togglePermission = (id: number, checked: boolean) => {
setForm((prev) => { setForm((prev) => {
const set = new Set(prev.permission_ids); const set = new Set(prev.permission_ids);
if (checked) { checked ? set.add(id) : set.delete(id);
set.add(id);
} else {
set.delete(id);
}
return { ...prev, permission_ids: Array.from(set) }; 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-slate-900 border-slate-800"> <DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2"> <DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Shield className="h-5 w-5 text-purple-400" /> <Shield className="h-5 w-5 text-purple-400" />
{editingRole ? '编辑角色' : '新建角色'} {editingRole ? '编辑角色' : '新建角色'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-slate-500"> <DialogDescription className="text-[var(--dg-text-secondary)]">
{editingRole ? '修改角色信息与权限' : '创建新的系统角色并分配权限'} {editingRole ? '修改角色信息与权限' : '创建新的系统角色并分配权限'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -76,68 +103,95 @@ export function RoleFormDialog({
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs text-slate-500">角色名称</Label> <Label className="text-xs text-[var(--dg-text-secondary)]">角色名称</Label>
<Input <Input
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => 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="如:运维工程师" placeholder="如:运维工程师"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs text-slate-500">角色编码</Label> <Label className="text-xs text-[var(--dg-text-secondary)]">角色编码</Label>
<Input <Input
value={form.code} value={form.code}
onChange={(e) => setForm({ ...form, code: e.target.value })} onChange={(e) => setForm({ ...form, code: e.target.value })}
disabled={!!editingRole} 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" placeholder="如:ops_engineer"
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs text-slate-500">描述</Label> <Label className="text-xs text-[var(--dg-text-secondary)]">描述</Label>
<Textarea <Textarea
value={form.description} value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })} onChange={(e) => setForm({ ...form, description: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500 resize-none" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] resize-none"
placeholder="角色职责描述(可选)" placeholder="角色职责描述(可选)"
rows={2} rows={2}
/> />
</div> </div>
{/* 权限分配 — 按模块分组 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs text-slate-500"> <Label className="text-xs text-[var(--dg-text-secondary)]">
权限分配 权限分配
{form.permission_ids.length > 0 && ( {form.permission_ids.length > 0 && (
<span className="ml-2 text-slate-400">已选 {form.permission_ids.length} 项</span> <span className="ml-2 text-purple-400">已选 {form.permission_ids.length} 项</span>
)} )}
</Label> </Label>
<div className="max-h-48 overflow-y-auto rounded-lg border border-slate-800 bg-slate-800/30 p-3"> <div className="max-h-[40vh] overflow-y-auto rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] p-3 space-y-3">
{permissions.length === 0 ? ( {grouped.length === 0 ? (
<p className="text-xs text-slate-500 text-center py-4">暂无可分配权限</p> <p className="text-xs text-[var(--dg-text-muted)] text-center py-4">暂无可分配权限</p>
) : ( ) : (
<div className="grid grid-cols-2 gap-2"> grouped.map(group => {
{permissions.map((perm) => ( const selectedInGroup = group.items.filter(p => form.permission_ids.includes(p.id)).length;
<Label const allSelected = selectedInGroup === group.items.length && group.items.length > 0;
key={perm.id} return (
className="flex items-center gap-2 text-xs text-slate-300 font-normal cursor-pointer hover:text-slate-100" <div key={group.key}>
> <div className="flex items-center justify-between mb-1.5">
<Checkbox <button
checked={form.permission_ids.includes(perm.id)} onClick={() => toggleGroup(group.items, !allSelected)}
onCheckedChange={(checked) => togglePermission(perm.id, checked === true)} className="text-xs font-medium text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors flex items-center gap-1"
className="border-slate-600 data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600" >
/> {group.label}
<span className="truncate">{perm.name}</span> <span className="text-[var(--dg-text-muted)] text-[10px]">
</Label> ({selectedInGroup}/{group.items.length})
))} </span>
</div> </button>
<button
onClick={() => toggleGroup(group.items, !allSelected)}
className="text-[10px] text-blue-400 hover:text-blue-300 transition-colors"
>
{allSelected ? '取消全选' : '全选'}
</button>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1 pl-1">
{group.items.map((perm) => (
<Label
key={perm.id}
className="flex items-center gap-2 text-xs text-[var(--dg-text-secondary)] font-normal cursor-pointer hover:text-[var(--dg-text-primary)] py-0.5"
>
<Checkbox
checked={form.permission_ids.includes(perm.id)}
onCheckedChange={(checked) => togglePermission(perm.id, checked === true)}
className="border-[var(--dg-border)] data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600 h-3.5 w-3.5"
/>
<span className="truncate">{perm.name}</span>
</Label>
))}
</div>
</div>
);
})
)} )}
</div> </div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => onOpenChange(false)}> <Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => onOpenChange(false)}>
取消 取消
</Button> </Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={onSubmit} disabled={saving}> <Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={onSubmit} disabled={saving}>
+157 -140
View File
@@ -4,61 +4,81 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
Dialog, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Plus, Shield, Edit, Eye, Trash2 } from 'lucide-react'; import { Plus, Shield, ShieldCheck, Edit, Eye, Trash2, Lock, Star } from 'lucide-react';
import { userApi } from '@/services/api'; import { userApi } from '@/services/api';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import type { Role } from '@/types'; import type { Role } from '@/types';
import { import { RoleFormDialog, type RoleFormData, type PermissionOption } from './RoleFormDialog';
RoleFormDialog,
type RoleFormData,
type PermissionOption,
} from './RoleFormDialog';
interface SystemRolesTabProps { interface SystemRolesTabProps {
selectedRole: Role | null; selectedRole: Role | null;
setSelectedRole: (role: Role | null) => void; setSelectedRole: (role: Role | null) => void;
} }
// 将后端角色数据规范化为前端 Role 类型 // Logan.Li // 角色图标映射
function normalizeRole( raw: any): Role { const ROLE_ICONS: Record<string, string> = {
super_admin: '👑',
k8s_admin: '☸️',
resource_admin: '🖥️',
monitor_admin: '📡',
security_auditor: '🔍',
release_admin: '🚀',
viewer: '👁️',
};
// 权限按模块分组
const PERM_MODULE_GROUPS: { label: string; prefixes: string[] }[] = [
{ label: '仪表盘', prefixes: ['dashboard:'] },
{ label: '资产管理', prefixes: ['server:'] },
{ label: '云资源', prefixes: ['cloud:'] },
{ label: 'K8s', prefixes: ['k8s:'] },
{ label: '发布', prefixes: ['deploy:'] },
{ label: '监控', prefixes: ['monitor:', 'agent:'] },
{ label: '自动化', prefixes: ['auto:'] },
{ label: 'AI', prefixes: ['aiops:'] },
{ label: '审批', prefixes: ['approval:'] },
{ label: '审计', prefixes: ['audit:'] },
{ label: '系统', prefixes: ['system:'] },
];
function groupPermNames(perms: string[], codes: string[]): { label: string; count: number }[] {
const grouped = PERM_MODULE_GROUPS.map(g => {
const matched = perms.filter((_, i) => g.prefixes.some(pre => (codes[i] || '').startsWith(pre)));
return { label: g.label, count: matched.length };
}).filter(g => g.count > 0);
return grouped;
}
// 将后端角色数据规范化为前端 Role 类型
function normalizeRole(raw: any): Role {
const rawPerms = Array.isArray(raw.permissions) ? raw.permissions : []; const rawPerms = Array.isArray(raw.permissions) ? raw.permissions : [];
return { return {
id: String(raw.id ?? ''), id: String(raw.id ?? ''),
name: raw.name ?? '', name: raw.name ?? '',
code: raw.code ?? '', code: raw.code ?? '',
description: raw.description ?? '', description: raw.description ?? '',
// 后端返回权限对象数组(含 name/code/type),映射为显示名数组 permissions: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.name ?? p?.code ?? '')).filter(Boolean),
permissions: rawPerms.map(( p: any) => (typeof p === 'string' ? p : p?.name ?? p?.code ?? '')).filter(Boolean), permissionCodes: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
permissionCodes: rawPerms.map(( p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
createdAt: raw.createdAt ?? raw.created_at ?? '', createdAt: raw.createdAt ?? raw.created_at ?? '',
// 保留原始权限 ID 供编辑使用 isSystem: raw.is_system ?? raw.isSystem ?? false,
_permissionIds: rawPerms _permissionIds: rawPerms
.map(( p: any) => (typeof p === 'object' ? Number(p.id) : 0)) .map((p: any) => (typeof p === 'object' ? Number(p.id) : 0))
.filter(Boolean), .filter(Boolean),
} as Role & { _permissionIds: number[]; permissionCodes: string[] }; _raw: raw,
} as Role & { _permissionIds: number[]; permissionCodes: string[]; isSystem: boolean; _raw: any };
} }
const emptyForm: RoleFormData = { const emptyForm: RoleFormData = {
name: '', name: '', code: '', description: '', permission_ids: [],
code: '',
description: '',
permission_ids: [],
}; };
export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTabProps) { export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTabProps) {
const confirm = useConfirm(); const confirm = useConfirm();
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [permissions, setPermissions] = useState<PermissionOption[]>([]); const [permissions, setPermissions] = useState<PermissionOption[]>([]);
// 新建 / 编辑弹窗
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingRole, setEditingRole] = useState<any | null>(null); const [editingRole, setEditingRole] = useState<any | null>(null);
const [form, setForm] = useState<RoleFormData>({ ...emptyForm }); const [form, setForm] = useState<RoleFormData>({ ...emptyForm });
@@ -82,11 +102,8 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
const resp = await userApi.getPermissions(); const resp = await userApi.getPermissions();
const list = resp?.list ?? []; const list = resp?.list ?? [];
setPermissions( setPermissions(
list.map(( p: any) => ({ list.map((p: any) => ({
id: Number(p.id), id: Number(p.id), name: p.name ?? p.code ?? '', code: p.code ?? '', type: p.type,
name: p.name ?? p.code ?? '',
code: p.code ?? '',
type: p.type,
})) }))
); );
} catch { } catch {
@@ -105,8 +122,8 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
setDialogOpen(true); setDialogOpen(true);
}; };
const openEdit = ( role: any) => { const openEdit = (role: any) => {
setEditingRole(role); setEditingRole(role._raw || role);
setForm({ setForm({
name: role.name ?? '', name: role.name ?? '',
code: role.code ?? '', code: role.code ?? '',
@@ -117,14 +134,8 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!form.name.trim()) { if (!form.name.trim()) { toast.error('请输入角色名称'); return; }
toast.error('请输入角色名称'); if (!form.code.trim()) { toast.error('请输入角色编码'); return; }
return;
}
if (!form.code.trim()) {
toast.error('请输入角色编码');
return;
}
setSaving(true); setSaving(true);
try { try {
const payload = { const payload = {
@@ -149,7 +160,11 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
} }
}; };
const handleDelete = async ( role: any) => { const handleDelete = async (role: any) => {
if (role.isSystem) {
toast.error('系统默认角色不可删除');
return;
}
const ok = await confirm({ const ok = await confirm({
title: '删除角色', title: '删除角色',
description: `确认删除角色「${role.name}」吗?该操作不可恢复。`, description: `确认删除角色「${role.name}」吗?该操作不可恢复。`,
@@ -162,16 +177,17 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
toast.success('角色已删除'); toast.success('角色已删除');
await loadRoles(); await loadRoles();
} catch (err: any) { } catch (err: any) {
toast.error(err?.message || '删除失败'); const msg = err?.response?.data?.message || err?.message || '删除失败';
toast.error(msg);
} }
}; };
return ( return (
<> <>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end gap-2">
<span className="text-xs text-[var(--dg-text-muted)]">{roles.length} 个角色</span>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}> <Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />新建角色
新建角色
</Button> </Button>
</div> </div>
@@ -180,77 +196,83 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div> </div>
) : roles.length === 0 ? ( ) : roles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500"> <div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
<Shield className="h-10 w-10 mb-3 opacity-40" /> <Shield className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm">暂无角色数据</p> <p className="text-sm">暂无角色数据</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{roles.map(( role: any) => ( {roles.map((role: any) => {
<Card key={role.id} className="bg-slate-900 border-slate-800"> const perms = role.permissions as string[];
<CardHeader className="pb-2"> const codes = role.permissionCodes as string[];
<div className="flex items-start justify-between"> const groupedPerms = groupPermNames(perms, codes);
<div className="flex items-center gap-3"> const icon = ROLE_ICONS[role.code] || '🛡️';
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center"> return (
<Shield className="h-5 w-5 text-purple-400" /> <Card key={role.id} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${!role.isSystem ? '' : 'border-purple-500/20'}`}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center text-lg ${
role.isSystem ? 'bg-purple-500/10' : 'bg-[var(--dg-bg-tertiary)]'
}`}>
{icon}
</div>
<div>
<CardTitle className="text-base text-[var(--dg-text-primary)] flex items-center gap-1.5">
{role.name}
{role.isSystem && (
<Badge className="bg-purple-500/15 text-purple-400 border-purple-500/30 text-[10px] px-1 py-0">
<Lock className="h-2.5 w-2.5 mr-0.5" />系统
</Badge>
)}
</CardTitle>
<CardDescription className="text-[var(--dg-text-muted)] text-xs">{role.code}</CardDescription>
</div>
</div> </div>
<div> <div className="flex items-center gap-1">
<CardTitle className="text-base text-slate-100">{role.name}</CardTitle> <Button
<CardDescription className="text-slate-500">{role.code}</CardDescription> variant="ghost" size="icon"
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
onClick={() => setSelectedRole(role)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost" size="icon"
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-blue-400"
onClick={() => openEdit(role)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost" size="icon"
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-red-400"
onClick={() => handleDelete(role)}
disabled={role.isSystem}
title={role.isSystem ? '系统默认角色不可删除' : '删除角色'}
>
<Trash2 className={`h-4 w-4 ${role.isSystem ? 'opacity-30' : ''}`} />
</Button>
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> </CardHeader>
<Button <CardContent>
variant="ghost" <p className="text-sm text-[var(--dg-text-secondary)] mb-3">{role.description || '暂无描述'}</p>
size="icon" {/* 按模块显示权限数 */}
className="h-8 w-8 text-slate-400 hover:text-slate-100" <div className="flex flex-wrap gap-1.5">
onClick={() => setSelectedRole(role)} {groupedPerms.map(g => (
> <Badge key={g.label} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] text-[10px]">
<Eye className="h-4 w-4" /> {g.label}·{g.count}
</Button> </Badge>
<Button ))}
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => openEdit(role)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDelete(role)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
</div> {perms.length === 0 && (
</CardHeader> <span className="text-xs text-[var(--dg-text-muted)]">未分配权限</span>
<CardContent>
<p className="text-sm text-slate-400 mb-3">{role.description || '暂无描述'}</p>
{/* 权限按模块分组显示 // Logan.Li */}
<div className="flex flex-wrap gap-1">
{(role.permissions as string[]).slice(0, 8).map((permission, idx) => (
<Badge key={idx} variant="outline" className="border-slate-700 text-slate-400 text-xs">
{permission}
</Badge>
))}
{(role.permissions as string[]).length > 8 && (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 text-xs">
+{(role.permissions as string[]).length - 8} 项
</Badge>
)} )}
{(role.permissions as string[]).length === 0 && ( </CardContent>
<span className="text-xs text-slate-600">未分配权限</span> </Card>
)} );
</div> })}
<div className="mt-2 text-xs text-slate-600">
共 {role.permissions?.length || 0} 项权限
</div>
</CardContent>
</Card>
))}
</div> </div>
)} )}
@@ -259,14 +281,9 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
{/* 新建 / 编辑角色 Dialog */} {/* 新建 / 编辑角色 Dialog */}
<RoleFormDialog <RoleFormDialog
open={dialogOpen} open={dialogOpen} onOpenChange={setDialogOpen}
onOpenChange={setDialogOpen} editingRole={editingRole} form={form} setForm={setForm}
editingRole={editingRole} saving={saving} onSubmit={handleSubmit} permissions={permissions}
form={form}
setForm={setForm}
saving={saving}
onSubmit={handleSubmit}
permissions={permissions}
/> />
</> </>
); );
@@ -274,50 +291,50 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
// 角色详情查看 Dialog // 角色详情查看 Dialog
function RoleDetailDialog({ function RoleDetailDialog({
role, role, onClose, onEdit,
onClose, }: { role: Role | null; onClose: () => void; onEdit: (role: Role) => void }) {
onEdit, const rawRole = role as any;
}: { const isSystem = rawRole?.isSystem ?? false;
role: Role | null;
onClose: () => void;
onEdit: (role: Role) => void;
}) {
return ( return (
<Dialog open={!!role} onOpenChange={(v) => !v && onClose()}> <Dialog open={!!role} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="bg-slate-900 border-slate-800"> <DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2"> <DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Shield className="h-5 w-5 text-purple-400" /> <ShieldCheck className="h-5 w-5 text-purple-400" />
{role?.name} {role?.name}
{isSystem && (
<Badge className="bg-purple-500/15 text-purple-400 border-purple-500/30 text-[10px]">
<Lock className="h-2.5 w-2.5 mr-0.5" />系统
</Badge>
)}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-slate-500">编码:{role?.code}</DialogDescription> <DialogDescription className="text-[var(--dg-text-muted)]">编码:{role?.code}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg bg-slate-800/50 p-4"> <div className="rounded-lg bg-[var(--dg-bg-tertiary)] p-4">
<p className="text-xs text-slate-500 mb-1">描述</p> <p className="text-xs text-[var(--dg-text-muted)] mb-1">描述</p>
<p className="text-sm text-slate-200">{role?.description || '暂无描述'}</p> <p className="text-sm text-[var(--dg-text-primary)]">{role?.description || '暂无描述'}</p>
</div> </div>
<div> <div>
<h4 className="text-sm font-medium text-slate-300 mb-2">权限列表 ({role?.permissions?.length || 0} 项)</h4> <h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-2">
{role && role.permissions.length > 0 ? ( 权限列表 ({role?.permissions?.length || 0} 项)
</h4>
{role && (role.permissions as string[]).length > 0 ? (
<div className="flex flex-wrap gap-2 max-h-60 overflow-y-auto"> <div className="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
{role.permissions.map((p: string, idx: number) => ( {(role.permissions as string[]).map((p, idx) => (
<Badge key={idx} variant="outline" className="border-slate-700 text-slate-300"> <Badge key={idx} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
{p} {p}
</Badge> </Badge>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xs text-slate-500">未分配权限</p> <p className="text-xs text-[var(--dg-text-muted)]">未分配权限</p>
)} )}
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => role && onEdit(role)}> <Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => role && onEdit(role)}>
<Edit className="h-4 w-4 mr-2" /> <Edit className="h-4 w-4 mr-2" />编辑
编辑
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -25,6 +25,7 @@ import { UserFormDialog, type UserFormData } from './UserFormDialog';
import { UserDetailDialog } from './UserDetailDialog'; import { UserDetailDialog } from './UserDetailDialog';
import { CsvImportDialog } from './CsvImportDialog'; import { CsvImportDialog } from './CsvImportDialog';
import { LdapImportDialog } from './LdapImportDialog'; import { LdapImportDialog } from './LdapImportDialog';
import { usePermission } from '@/hooks/usePermission';
interface SystemUserTabProps { interface SystemUserTabProps {
// Search // Search
@@ -108,6 +109,7 @@ export function SystemUserTab({
handleLdapImport, handleLdapImport,
setLdapUsers, setLdapUsers,
}: SystemUserTabProps) { }: SystemUserTabProps) {
const { hasPermission } = usePermission();
return ( return (
<> <>
{/* Filters */} {/* Filters */}
@@ -122,18 +124,24 @@ export function SystemUserTab({
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasPermission('system:user') && (
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('csv')}> <Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('csv')}>
<Upload className="h-4 w-4 mr-2" /> <Upload className="h-4 w-4 mr-2" />
批量导入 批量导入
</Button> </Button>
)}
{hasPermission('system:user') && (
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('ldap')}> <Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('ldap')}>
<Network className="h-4 w-4 mr-2" /> <Network className="h-4 w-4 mr-2" />
LDAP 导入 LDAP 导入
</Button> </Button>
)}
{hasPermission('system:user') && (
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}> <Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
新建用户 新建用户
</Button> </Button>
)}
</div> </div>
</div> </div>
@@ -232,6 +240,7 @@ export function SystemUserTab({
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
{hasPermission('system:user') && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -240,6 +249,8 @@ export function SystemUserTab({
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
)}
{hasPermission('system:user') && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -248,6 +259,7 @@ export function SystemUserTab({
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
)}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
+61
View File
@@ -0,0 +1,61 @@
/**
* usePermission — 前端 RBAC 按钮级权限控制 Hook
*
* 设计:
* - 权限码存储在 zustand user store 的 `permissions` 字段
* - admin 用户 permissions = ["*"],所有 hasPermission 返回 true
* - 普通用户精确匹配权限码
* - 权限未加载(null)时默认放行(避免页面空白闪烁),加载后过滤
*
* 用法:
* const { hasPermission, hasAnyPermission } = usePermission();
* if (hasPermission('server:create')) { ... }
*
* 或用 <HasPermission> 组件包裹:
* <HasPermission permission="server:delete">
* <Button>删除</Button>
* </HasPermission>
*/
import { useUserStore } from '@/store';
// 权限通配符 — admin 用户拥有
const WILDCARD = '*';
export function usePermission() {
const permissions = useUserStore((s) => s.permissions);
// admin 通配符短路
const isAdmin = permissions?.includes(WILDCARD) ?? false;
/**
* 检查是否拥有指定权限
* @param code 权限码,如 'server:create'
* @returns true=有权限;permissions 未加载时默认 true
*/
const hasPermission = (code: string): boolean => {
// 权限未加载(登录后未调用 setPermissions)→ 默认放行
if (permissions === null || permissions === undefined) return true;
if (isAdmin) return true;
return permissions.includes(code);
};
/**
* 检查是否拥有任意一个权限(OR 逻辑)
*/
const hasAnyPermission = (codes: string[]): boolean => {
if (permissions === null || permissions === undefined) return true;
if (isAdmin) return true;
return codes.some((c) => permissions.includes(c));
};
/**
* 检查是否拥有全部权限(AND 逻辑)
*/
const hasAllPermissions = (codes: string[]): boolean => {
if (permissions === null || permissions === undefined) return true;
if (isAdmin) return true;
return codes.every((c) => permissions.includes(c));
};
return { hasPermission, hasAnyPermission, hasAllPermissions, isAdmin };
}
+310 -101
View File
@@ -12,35 +12,30 @@ import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Plus, Search, RefreshCw, Clock, CheckCircle, XCircle, FileText, Loader2 } from 'lucide-react'; import { Plus, Search, RefreshCw, Clock, CheckCircle, XCircle, FileText, Loader2, Eye, Check, XIcon, User, ChevronRight, Play } from 'lucide-react';
import { approvalApi } from '@/services/api'; import { approvalApi } from '@/services/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils';
export interface FormField { export interface FormField {
key: string; key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea';
label: string; options?: string[]; placeholder?: string; defaultValue?: string | number; required?: boolean;
type: 'text' | 'number' | 'select' | 'textarea';
options?: string[];
placeholder?: string;
defaultValue?: string | number;
required?: boolean;
} }
interface Props { interface Props {
category: 'resource' | 'app' | 'permission'; category: 'resource' | 'app' | 'permission';
title: string; title: string; description: string; fields: FormField[]; templateCode: string;
description: string; }
fields: FormField[];
templateCode: string; interface StepItem {
id: number; step: number; step_type?: string; approver: number; status: string; comment: string;
approved_at: string | null; approver_user?: { username?: string; real_name?: string };
} }
interface ProcessItem { interface ProcessItem {
id: number; id: number; title: string; status: string; form_data: string; created_at: string;
title: string; applicant: number; applicant_user?: { username?: string; real_name?: string };
status: string; template?: { name?: string }; current_step?: number; total_steps?: number;
form_data: string; steps?: StepItem[];
created_at: string;
} }
const statusBadge = (status: string) => { const statusBadge = (status: string) => {
@@ -51,6 +46,25 @@ const statusBadge = (status: string) => {
} }
}; };
function parseFormData(data: string): Record<string, string> {
try { return JSON.parse(data) || {}; } catch { return {}; }
}
function getApplicant(p: any): string {
const u = p?.applicant_user;
return u ? (u.real_name || u.username || `#${p.applicant}`) : (p?.applicant ? `#${p.applicant}` : '--');
}
function getCurrentApprover(p: ProcessItem): string {
if (p.status !== 'pending') return '--';
const steps = p.steps || [];
if (steps.length === 0) return '管理员(待分配)';
const cs = steps.find(s => s.step === p.current_step && s.status === 'pending');
if (!cs) return '管理员(待分配)';
if (cs.approver_user) return cs.approver_user.real_name || cs.approver_user.username || `#${cs.approver}`;
return '管理员';
}
export function ApprovalCategoryPage({ category, title, description, fields, templateCode }: Props) { export function ApprovalCategoryPage({ category, title, description, fields, templateCode }: Props) {
const [processes, setProcesses] = useState<ProcessItem[]>([]); const [processes, setProcesses] = useState<ProcessItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -60,20 +74,19 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [templateId, setTemplateId] = useState<number | null>(null); const [templateId, setTemplateId] = useState<number | null>(null);
const [formData, setFormData] = useState<Record<string, string>>({}); const [formData, setFormData] = useState<Record<string, string>>({});
const [detailItem, setDetailItem] = useState<ProcessItem | null>(null);
const [acting, setActing] = useState(false);
const fetchProcesses = useCallback(async () => { const fetchProcesses = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { page_size: '100' }; const params: any = { page_size: '100' };
if (category) params.category = category; if (category) params.category = category;
if (statusFilter) params.status = statusFilter; if (statusFilter) params.status = statusFilter;
const resp = (await approvalApi.getProcesses(params)) as any; const resp = (await approvalApi.getProcesses(params)) as any;
setProcesses(resp?.list ?? resp?.data?.list ?? []); setProcesses(resp?.list ?? resp?.data?.list ?? []);
} catch { } catch { setProcesses([]); }
setProcesses([]); finally { setLoading(false); }
} finally {
setLoading(false);
}
}, [category, statusFilter]); }, [category, statusFilter]);
const fetchTemplateId = useCallback(async () => { const fetchTemplateId = useCallback(async () => {
@@ -81,7 +94,7 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
const resp = (await approvalApi.getTemplates({ type: templateCode })) as any; const resp = (await approvalApi.getTemplates({ type: templateCode })) as any;
const list = resp?.list ?? resp?.data?.list ?? []; const list = resp?.list ?? resp?.data?.list ?? [];
if (list.length > 0) setTemplateId(list[0].id); if (list.length > 0) setTemplateId(list[0].id);
} catch { /* ignore */ } } catch {}
}, [templateCode]); }, [templateCode]);
useEffect(() => { fetchProcesses(); }, [fetchProcesses]); useEffect(() => { fetchProcesses(); }, [fetchProcesses]);
@@ -89,50 +102,56 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
const handleCreate = async () => { const handleCreate = async () => {
if (!templateId) { toast.error('模板未加载'); return; } if (!templateId) { toast.error('模板未加载'); return; }
// Validate required fields for (const f of fields) { if (f.required && !formData[f.key]) { toast.error(`请填写「${f.label}」`); return; } }
for (const f of fields) {
if (f.required && !formData[f.key]) {
toast.error(`请填写「${f.label}」`);
return;
}
}
setSubmitting(true); setSubmitting(true);
try { try {
// Build title from first 2 fields
const firstVal = formData[fields[0]?.key] || ''; const firstVal = formData[fields[0]?.key] || '';
const secondVal = fields[1] ? formData[fields[1].key] || '' : ''; const secondVal = fields[1] ? formData[fields[1].key] || '' : '';
const autoTitle = `${title} - ${firstVal}${secondVal ? ' / ' + secondVal : ''}`;
await approvalApi.createProcess({ await approvalApi.createProcess({
template_id: templateId, template_id: templateId,
title: autoTitle, title: `${title} - ${firstVal}${secondVal ? ' / ' + secondVal : ''}`,
form_data: JSON.stringify(formData), form_data: JSON.stringify(formData),
}); });
toast.success('申请已提交'); toast.success('申请已提交');
setDialogOpen(false); setDialogOpen(false); setFormData({}); fetchProcesses();
setFormData({}); } catch (err: any) { toast.error(err?.message || '提交失败'); }
fetchProcesses(); finally { setSubmitting(false); }
} catch (err: any) {
toast.error(err?.message || '提交失败');
} finally {
setSubmitting(false);
}
}; };
const openCreateDialog = () => { const openCreateDialog = () => {
const init: Record<string, string> = {}; const init: Record<string, string> = {};
fields.forEach((f) => { init[f.key] = f.defaultValue?.toString() || ''; }); fields.forEach((f) => { init[f.key] = f.defaultValue?.toString() || ''; });
setFormData(init); setFormData(init); setDialogOpen(true);
setDialogOpen(true);
}; };
const filtered = processes.filter((p) => { const handleApprove = async (id: number) => {
return !keyword || (p.title || '').toLowerCase().includes(keyword.toLowerCase()); setActing(true);
}); try { await approvalApi.approveProcess(id); toast.success('已通过'); fetchProcesses(); }
catch (err: any) { toast.error(err?.response?.data?.message || '审批失败'); }
finally { setActing(false); }
};
const handleReject = async (id: number) => {
setActing(true);
try { await approvalApi.rejectProcess(id, '审批拒绝'); toast.success('已拒绝'); fetchProcesses(); }
catch (err: any) { toast.error(err?.response?.data?.message || '操作失败'); }
finally { setActing(false); }
};
const openDetail = async (p: ProcessItem) => {
// Load full detail including steps
try {
const resp: any = await approvalApi.getProcesses({ page_size: 200 } as any);
const list = resp?.list ?? resp?.data?.list ?? [];
const full = list.find((x: any) => x.id === p.id);
setDetailItem(full || p);
} catch { setDetailItem(p); }
};
const filtered = processes.filter((p) => !keyword || (p.title || '').toLowerCase().includes(keyword.toLowerCase()));
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{title}</h1> <h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{title}</h1>
@@ -143,16 +162,11 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
</Button> </Button>
</div> </div>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="relative w-64"> <div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
<Input <Input placeholder="搜索申请..." value={keyword} onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索申请..." className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div> </div>
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"> <SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
@@ -170,13 +184,10 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
</Button> </Button>
</div> </div>
{/* Table */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0"> <CardContent className="p-0">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-blue)]" /></div>
<Loader2 className="h-6 w-6 animate-spin text-[var(--dg-blue)]" />
</div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]"> <div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
<FileText className="h-10 w-10 mb-3 opacity-30" /> <FileText className="h-10 w-10 mb-3 opacity-30" />
@@ -187,21 +198,40 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent"> <TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-[var(--dg-text-secondary)]">ID</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">申请标题</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">提交时间</TableHead> <TableHead className="text-[var(--dg-text-secondary)]">提交时间</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">提交账号</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">当前审核人</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
<TableHead className="text-[var(--dg-text-secondary)] w-28">操作</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filtered.map((p) => ( {filtered.map((p) => (
<TableRow key={p.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"> <TableRow key={p.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<TableCell className="text-[var(--dg-text-muted)] text-xs">#{p.id}</TableCell> <TableCell className="text-[var(--dg-text-muted)] text-sm whitespace-nowrap">
<TableCell className="text-[var(--dg-text-primary)] font-medium">{p.title}</TableCell>
<TableCell>{statusBadge(p.status)}</TableCell>
<TableCell className="text-[var(--dg-text-muted)] text-sm">
{p.created_at ? new Date(p.created_at).toLocaleString('zh-CN') : '--'} {p.created_at ? new Date(p.created_at).toLocaleString('zh-CN') : '--'}
</TableCell> </TableCell>
<TableCell className="text-[var(--dg-text-primary)]">{getApplicant(p)}</TableCell>
<TableCell className="text-[var(--dg-text-muted)] text-sm">{getCurrentApprover(p)}</TableCell>
<TableCell>{statusBadge(p.status)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{p.status === 'pending' ? (
<>
<Button variant="ghost" size="sm" className="h-8 text-red-400 hover:bg-red-500/10" onClick={() => handleReject(p.id)} disabled={acting}>
<XIcon className="h-3.5 w-3.5 mr-1" />拒绝
</Button>
<Button variant="ghost" size="sm" className="h-8 text-emerald-400 hover:bg-emerald-500/10" onClick={() => handleApprove(p.id)} disabled={acting}>
<Check className="h-3.5 w-3.5 mr-1" />通过
</Button>
</>
) : (
<Button variant="ghost" size="sm" className="h-8 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]" onClick={() => openDetail(p)}>
<Eye className="h-3.5 w-3.5 mr-1" />详情
</Button>
)}
</div>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -220,54 +250,233 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
{fields.map((field) => ( {fields.map((field) => (
<div key={field.key} className="space-y-1.5"> <div key={field.key} className="space-y-1.5">
<Label className="text-[var(--dg-text-primary)] text-sm"> <Label className="text-[var(--dg-text-primary)] text-sm">{field.label}{field.required && <span className="text-red-400 ml-0.5">*</span>}</Label>
{field.label}
{field.required && <span className="text-red-400 ml-0.5">*</span>}
</Label>
{field.type === 'select' ? ( {field.type === 'select' ? (
<Select <Select value={formData[field.key] || ''} onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}>
value={formData[field.key] || ''} <SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"><SelectValue placeholder={field.placeholder || `选择${field.label}`} /></SelectTrigger>
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))} <SelectContent>{(field.options || []).map((opt) => (<SelectItem key={opt} value={opt}>{opt}</SelectItem>))}</SelectContent>
>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectValue placeholder={field.placeholder || `选择${field.label}`} />
</SelectTrigger>
<SelectContent>
{(field.options || []).map((opt) => (
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
))}
</SelectContent>
</Select> </Select>
) : field.type === 'textarea' ? ( ) : field.type === 'textarea' ? (
<textarea <textarea value={formData[field.key] || ''} onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
value={formData[field.key] || ''} placeholder={field.placeholder} rows={3}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} className="w-full rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] px-3 py-2 text-sm resize-none focus:outline-none focus:border-[var(--dg-blue)]" />
placeholder={field.placeholder}
rows={3}
className="w-full rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] px-3 py-2 text-sm resize-none focus:outline-none focus:border-[var(--dg-blue)]"
/>
) : ( ) : (
<Input <Input type={field.type} value={formData[field.key] || ''} onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
type={field.type} placeholder={field.placeholder} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
value={formData[field.key] || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
)} )}
</div> </div>
))} ))}
</div> </div>
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-2">
<Button variant="outline" className="border-[var(--dg-border)]" onClick={() => setDialogOpen(false)}> <Button variant="outline" className="border-[var(--dg-border)]" onClick={() => setDialogOpen(false)}>取消</Button>
取消
</Button>
<Button onClick={handleCreate} disabled={submitting} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/90"> <Button onClick={handleCreate} disabled={submitting} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/90">
{submitting ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />提交中...</> : '提交申请'} {submitting ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />提交中...</> : '提交申请'}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Detail Dialog — operation timeline */}
<DetailTimelineDialog item={detailItem} onClose={() => setDetailItem(null)} fields={fields} onStatusChange={fetchProcesses} />
</div> </div>
); );
} }
/* ------------------------------------------------------------------ */
/* DetailTimelineDialog — 操作时间线 */
/* ------------------------------------------------------------------ */
function DetailTimelineDialog({ item, onClose, fields, onStatusChange }: {
item: ProcessItem | null; onClose: () => void; fields: FormField[]; onStatusChange: () => void;
}) {
const [acting, setActing] = useState(false);
if (!item) return null;
const timeStr = (t: string | null) => {
if (!t) return '';
return new Date(t).toLocaleString('zh-CN');
};
const steps = item.steps || [];
const handleApprove = async () => {
setActing(true);
try { await approvalApi.approveProcess(item.id); toast.success('已通过'); onStatusChange(); onClose(); }
catch (err: any) { toast.error(err?.response?.data?.message || '审批失败'); }
finally { setActing(false); }
};
const handleReject = async () => {
setActing(true);
try { await approvalApi.rejectProcess(item.id, '审批拒绝'); toast.success('已拒绝'); onStatusChange(); onClose(); }
catch (err: any) { toast.error(err?.response?.data?.message || '操作失败'); }
finally { setActing(false); }
};
const stepStatusLabel = (s: StepItem) => {
if (s.status === 'approved') return '通过';
if (s.status === 'rejected') return '拒绝';
if (s.status === 'pending' && s.step === item.current_step) return '待审批';
if (s.status === 'pending') return '待开始';
return s.status;
};
const stepStatusColor = (s: StepItem) => {
if (s.status === 'approved') return 'text-emerald-400';
if (s.status === 'rejected') return 'text-red-400';
if (s.step === item.current_step) return 'text-blue-400';
return 'text-[var(--dg-text-muted)]';
};
const approverName = (s: StepItem) => {
if (s.approver_user) return s.approver_user.real_name || s.approver_user.username || `#${s.approver}`;
return '管理员';
};
return (
<Dialog open={!!item} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">申请详情</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">{item.title}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Status + Meta */}
<div className="flex items-center gap-3">
<span className="text-sm text-[var(--dg-text-muted)]">状态:</span>
{statusBadge(item.status)}
{item.status === 'pending' && (
<span className="text-xs text-[var(--dg-text-muted)]">
第 {item.current_step}/{item.total_steps} 步
</span>
)}
</div>
{/* Operation Timeline */}
<div className="border-t border-[var(--dg-border)] pt-4">
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-4">操作时间线</h4>
<div className="space-y-0">
{/* 1. Submit node */}
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className="w-7 h-7 rounded-full bg-blue-500/20 flex items-center justify-center"><User className="w-3.5 h-3.5 text-blue-400" /></div>
<div className="w-0.5 flex-1 bg-[var(--dg-border)] my-1" />
</div>
<div className="flex-1 pb-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--dg-text-primary)]">提交申请</span>
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
<span className="text-xs text-[var(--dg-text-secondary)]">{getApplicant(item)}</span>
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
<span className="text-xs text-[var(--dg-text-muted)]">{timeStr(item.created_at)}</span>
</div>
{/* Form data summary */}
<div className="mt-1 flex flex-wrap gap-2">
{(() => {
const data = parseFormData(item.form_data || '{}');
return fields.slice(0, 4).map((f) => {
const val = data[f.key];
if (!val) return null;
return (
<span key={f.key} className="text-[11px] px-1.5 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">
{f.label}: <span className="text-[var(--dg-text-secondary)]">{val}</span>
</span>
);
});
})()}
</div>
</div>
</div>
{/* 2. Approval / Execution steps */}
{steps.map((s, i) => {
const isLast = i === steps.length - 1;
const isExecute = s.step_type === 'execute';
const stepLabel = isExecute ? '执行' : '审核';
const iconColor = s.status === 'approved' ? 'bg-emerald-500/20'
: s.status === 'rejected' ? 'bg-red-500/20'
: 'bg-[var(--dg-bg-tertiary)]';
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center">
<div className={`w-7 h-7 rounded-full flex items-center justify-center ${iconColor}`}>
{s.status === 'approved' ? <Check className="w-3.5 h-3.5 text-emerald-400" />
: s.status === 'rejected' ? <XIcon className="w-3.5 h-3.5 text-red-400" />
: isExecute
? <Play className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'text-green-400' : 'text-[var(--dg-text-muted)]'}`} />
: <ChevronRight className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />}
</div>
{!isLast && <div className="w-0.5 flex-1 bg-[var(--dg-border)] my-1" />}
</div>
<div className={`flex-1 ${!isLast ? 'pb-4' : ''}`}>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{stepLabel}</span>
{s.approver_user && (
<>
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
<span className="text-xs text-[var(--dg-text-secondary)]">指派人: {approverName(s)}</span>
</>
)}
{s.approved_at && (
<>
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
<span className={`text-xs ${stepStatusColor(s)}`}>
{approverName(s)} · {stepStatusLabel(s)} · {timeStr(s.approved_at)}
</span>
</>
)}
{!s.approved_at && (
<>
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
<span className={`text-xs ${stepStatusColor(s)}`}>{stepStatusLabel(s)}</span>
</>
)}
</div>
{s.comment && s.comment !== s.status && s.comment !== 'pending' && (
<p className="text-xs text-[var(--dg-text-muted)] mt-1 ml-0">{s.comment}</p>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Full form data */}
<div className="border-t border-[var(--dg-border)] pt-4">
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-2">申请内容</h4>
<div className="space-y-2">
{(() => {
const data = parseFormData(item.form_data || '{}');
return fields.map((f) => {
const val = data[f.key];
return (
<div key={f.key} className="grid grid-cols-3 gap-2 text-sm">
<span className="text-[var(--dg-text-muted)]">{f.label}:</span>
<span className="text-[var(--dg-text-primary)] col-span-2">{val ?? '--'}</span>
</div>
);
});
})()}
</div>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
{item.status === 'pending' && (
<>
<Button variant="outline" className="border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleReject} disabled={acting}>
<XIcon className="h-4 w-4 mr-1.5" />拒绝
</Button>
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={handleApprove} disabled={acting}>
<Check className="h-4 w-4 mr-1.5" />通过
</Button>
</>
)}
<Button variant="outline" className="border-[var(--dg-border)]" onClick={onClose}>关闭</Button>
</div>
</DialogContent>
</Dialog>
);
}
+677
View File
@@ -0,0 +1,677 @@
// 审批流程配置 — 参考 OpsxSQL WorkflowPage 工作流模式
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Plus, Edit2, Trash2, X, ChevronDown, ChevronUp, ArrowUp, ArrowDown,
Play, Loader2, Star, CheckCircle, GitBranch, Users, Shield,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { http } from '@/services/http';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface WorkflowStep {
id?: number;
workflow_id?: number;
step_number: number;
name: string;
step_type: string;
approver_ids: string | null;
is_required: boolean;
timeout_hours: number;
auto_approve_on_timeout: boolean;
}
interface WorkflowItem {
id: number;
name: string;
description: string | null;
category: string;
is_default: boolean;
is_active: boolean;
created_at: string;
steps: WorkflowStep[];
}
interface UserItem {
id: number;
username: string;
display_name: string | null;
role: string;
is_active?: boolean;
}
interface StepDraft {
name: string;
step_type: 'submit' | 'audit' | 'execute';
approver_ids: number[];
}
const CATEGORY_LABELS: Record<string, string> = {
resource: '资源申请',
app: '应用申请',
permission: '权限审批',
};
const STEP_TYPE_LABELS: Record<string, string> = {
submit: '提交阶段',
audit: '审核阶段',
execute: '执行阶段',
};
const STEP_TYPE_COLORS: Record<string, string> = {
submit: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
audit: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
execute: 'bg-green-500/15 text-green-400 border-green-500/30',
};
const STEP_TYPE_ICONS: Record<string, React.ElementType> = {
submit: Users,
audit: Shield,
execute: Play,
};
const STEP_TYPES = [
{ value: 'submit' as const, label: '提交阶段', desc: '工单提交人' },
{ value: 'audit' as const, label: '审核阶段', desc: '审核人员审批' },
{ value: 'execute' as const, label: '执行阶段', desc: '执行人员操作' },
];
// API helpers
const workflowApi = {
list: (params?: { category?: string }) => http.get('/approval-workflows', params),
get: (id: number) => http.get(`/approval-workflows/${id}`),
create: (data: any) => http.post('/approval-workflows', data),
update: (id: number, data: any) => http.put(`/approval-workflows/${id}`, data),
delete: (id: number) => http.delete(`/approval-workflows/${id}`),
setDefault: (id: number) => http.put(`/approval-workflows/${id}/default`),
};
const userListApi = () => http.get('/admin/users');
/* ------------------------------------------------------------------ */
/* UserSelectInline */
/* ------------------------------------------------------------------ */
function UserSelectInline({
users, selectedIds, label, onToggle, onToggleAll, onClear, getUserName,
}: {
users: UserItem[];
selectedIds: number[];
label: string;
onToggle: (uid: number) => void;
onToggleAll: () => void;
onClear: () => void;
getUserName: (uid: number) => string;
}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
const updatePos = () => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}
};
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => { if (!open) updatePos(); setOpen(!open); }}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${
selectedIds.length === 0
? 'bg-[var(--dg-bg-tertiary)] border border-red-500/40 text-[var(--dg-text-muted)]'
: 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)]'
}`}
>
<span className="truncate">
{selectedIds.length === 0 ? `${label} * 请选择` : `${label} (${selectedIds.length}人)`}
</span>
<ChevronDown className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{selectedIds.length > 1 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{selectedIds.map((uid) => (
<span key={uid} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-blue-500/15 text-blue-400">
{getUserName(uid)}
<button onClick={() => onToggle(uid)} className="hover:text-red-400"><X className="w-3 h-3" /></button>
</span>
))}
</div>
)}
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="fixed z-50 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl max-h-52 overflow-y-auto"
style={{ top: pos.top, left: pos.left, width: pos.width }}>
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--dg-border)]">
<button onClick={onToggleAll} className="text-[11px] text-blue-400 hover:text-blue-300">
{selectedIds.length === users.length && users.length > 0 ? '取消全选' : '全选'}
</button>
{selectedIds.length > 0 && (
<button onClick={onClear} className="text-[11px] text-red-400 hover:text-red-300">清空</button>
)}
</div>
{users.map((u) => {
const checked = selectedIds.includes(u.id);
return (
<div key={u.id} onClick={() => onToggle(u.id)}
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-[var(--dg-bg-hover)] transition-colors">
<div className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
checked ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)]'
}`}>
{checked && <CheckCircle className="w-3 h-3 text-white" />}
</div>
<span className={`text-sm flex-1 ${checked ? 'text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-secondary)]'}`}>
{u.display_name || u.username}
</span>
<span className="text-[10px] text-[var(--dg-text-muted)] px-1.5 py-0.5 rounded bg-[var(--dg-bg-tertiary)]">
{u.role}
</span>
</div>
);
})}
</div>
</>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* WorkflowEditorModal */
/* ------------------------------------------------------------------ */
interface EditorProps {
workflow?: WorkflowItem | null;
users: UserItem[];
onClose: () => void;
onSaved: () => void;
}
function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps) {
const isEdit = !!workflow;
const [name, setName] = useState(workflow?.name || '');
const [description, setDescription] = useState(workflow?.description || '');
const [category, setCategory] = useState(workflow?.category || 'resource');
const [steps, setSteps] = useState<StepDraft[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (workflow?.steps?.length) {
setSteps(
workflow.steps.map((s) => ({
name: s.name,
step_type: (s.step_type === 'serial' || s.step_type === 'parallel')
? ('audit' as const)
: (s.step_type as 'submit' | 'audit' | 'execute'),
approver_ids: parseIds(s.approver_ids),
}))
);
} else {
setSteps([
{ name: '提交申请', step_type: 'submit', approver_ids: [] },
{ name: '审核', step_type: 'audit', approver_ids: [] },
]);
}
}, [workflow]);
const parseIds = (ids: string | null): number[] => {
if (!ids) return [];
try {
const p = JSON.parse(ids);
return Array.isArray(p) ? p : [];
} catch { return []; }
};
const addStep = (type: 'submit' | 'audit' | 'execute') => {
const labels: Record<string, string> = { submit: '提交', audit: '审核', execute: '执行' };
const count = steps.filter((s) => s.step_type === type).length;
const suffix = count > 0 ? ` ${count + 1}` : '';
setSteps([...steps, { name: `${labels[type]}${suffix}`, step_type: type, approver_ids: [] }]);
};
const removeStep = (idx: number) => setSteps(steps.filter((_, i) => i !== idx));
const moveStep = (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= steps.length) return;
const next = [...steps];
[next[idx], next[target]] = [next[target], next[idx]];
setSteps(next);
};
const updateStep = (idx: number, patch: Partial<StepDraft>) => {
setSteps(steps.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
};
const toggleUser = (idx: number, uid: number) => {
const step = steps[idx];
const has = step.approver_ids.includes(uid);
updateStep(idx, { approver_ids: has ? step.approver_ids.filter((id) => id !== uid) : [...step.approver_ids, uid] });
};
const getEligibleUsers = (stepType: string) => {
const active = users.filter(u => u.is_active !== false);
if (stepType === 'audit') return active;
if (stepType === 'execute') return active.filter(u =>
['super_admin', 'k8s_admin', 'release_admin', 'resource_admin'].includes(u.role)
);
return active;
};
const toggleAllUsers = (idx: number) => {
const step = steps[idx];
const eligible = getEligibleUsers(step.step_type);
const allSelected = step.approver_ids.length === eligible.length && eligible.length > 0;
updateStep(idx, { approver_ids: allSelected ? [] : eligible.map((u) => u.id) });
};
const handleSubmit = async () => {
if (!name.trim()) { setError('请输入流程名称'); return; }
if (steps.length === 0) { setError('请至少添加一个步骤'); return; }
for (let i = 0; i < steps.length; i++) {
if (steps[i].step_type !== 'submit' && steps[i].approver_ids.length === 0) {
setError(`步骤"${steps[i].name}"需要关联至少一个审批人`);
return;
}
}
setSubmitting(true);
setError('');
try {
const payload = {
name: name.trim(),
description: description.trim() || null,
category,
steps: steps.map((s, i) => ({
step_number: i + 1,
name: s.name,
step_type: s.step_type,
approver_ids: s.approver_ids.length > 0 ? JSON.stringify(s.approver_ids) : null,
is_required: true,
timeout_hours: 24,
auto_approve_on_timeout: false,
})),
};
if (isEdit && workflow) {
await workflowApi.update(workflow.id, payload);
} else {
await workflowApi.create(payload);
}
onSaved();
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败';
setError(msg);
} finally {
setSubmitting(false);
}
};
const getUserName = (uid: number) => {
const u = users.find((u) => u.id === uid);
return u ? (u.display_name || u.username) : `#${uid}`;
};
const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors text-[var(--dg-text-primary)]';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--dg-border)]">
<h2 className="text-base font-semibold text-[var(--dg-text-primary)]">
{isEdit ? '编辑审批流程' : '新建审批流程'}
</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 pb-5 space-y-4 pt-4">
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">流程名称 *</label>
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} placeholder="输入流程名称" />
</div>
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">所属分类</label>
<select className={inputCls} value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="resource">资源申请</option>
<option value="app">应用申请</option>
<option value="permission">权限审批</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">描述</label>
<input className={inputCls} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="可选" />
</div>
{/* Steps */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-[var(--dg-text-secondary)]">流程步骤</label>
<div className="flex items-center gap-1">
{STEP_TYPES.map((t) => (
<button key={t.value} onClick={() => addStep(t.value)}
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 rounded transition-colors">
<Plus className="w-3 h-3" />{t.label}
</button>
))}
</div>
</div>
<div className="space-y-2">
{steps.map((step, idx) => {
const colorCls = STEP_TYPE_COLORS[step.step_type];
const Icon = STEP_TYPE_ICONS[step.step_type] || Shield;
return (
<div key={idx} className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-center gap-2 mb-2">
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${colorCls.split(' ')[0]}`}>
<Icon className={`w-3.5 h-3.5 ${colorCls.split(' ')[1]}`} />
</div>
<input
className="flex-1 px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-sm focus:border-blue-400 focus:outline-none text-[var(--dg-text-primary)]"
value={step.name} onChange={(e) => updateStep(idx, { name: e.target.value })}
placeholder="步骤名称"
/>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colorCls}`}>
{STEP_TYPE_LABELS[step.step_type]}
</span>
<div className="flex items-center gap-0.5 ml-1">
<button onClick={() => moveStep(idx, -1)} disabled={idx === 0}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors">
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button onClick={() => moveStep(idx, 1)} disabled={idx === steps.length - 1}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors">
<ArrowDown className="w-3.5 h-3.5" />
</button>
<button onClick={() => removeStep(idx)}
className="p-1 rounded hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{step.step_type !== 'submit' && (
<div className="pl-9 mt-2">
<UserSelectInline
users={getEligibleUsers(step.step_type)}
selectedIds={step.approver_ids}
label={step.step_type === 'audit' ? '审核人' : '执行人'}
onToggle={(uid) => toggleUser(idx, uid)}
onToggleAll={() => toggleAllUsers(idx)}
onClear={() => updateStep(idx, { approver_ids: [] })}
getUserName={getUserName}
/>
</div>
)}
</div>
);
})}
{steps.length === 0 && (
<div className="text-center py-4 text-sm text-[var(--dg-text-muted)]">
请点击上方按钮添加流程步骤
</div>
)}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-[var(--dg-border)]">
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">
取消
</button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting && <Loader2 className="w-4 h-4 animate-spin mr-1" />}
{isEdit ? '保存' : '创建'}
</Button>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Main Page */
/* ------------------------------------------------------------------ */
export default function ApprovalWorkflowPage() {
const [items, setItems] = useState<WorkflowItem[]>([]);
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [editorWorkflow, setEditorWorkflow] = useState<WorkflowItem | null | undefined>(undefined);
const fetchWorkflows = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res: any = await workflowApi.list();
const data = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : []);
const normalized = data.map((w: any) => ({ ...w, steps: w.steps || [] }));
setItems(normalized);
} catch {
setError('获取审批流程失败');
} finally {
setLoading(false);
}
}, []);
const fetchUsers = useCallback(async () => {
try {
const res: any = await userListApi();
const data = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : []);
// Normalize: extract role code from roles array
const normalized = data.map((u: any) => ({
...u,
role: u.role || (Array.isArray(u.roles) && u.roles.length > 0 ? u.roles[0].code : ''),
}));
setUsers(normalized);
} catch { /* ignore */ }
}, []);
useEffect(() => {
fetchWorkflows();
fetchUsers();
}, [fetchWorkflows, fetchUsers]);
const toggleActive = useCallback(async (wf: WorkflowItem) => {
try {
await workflowApi.update(wf.id, { is_active: !wf.is_active });
setItems((prev) => prev.map((w) => (w.id === wf.id ? { ...w, is_active: !w.is_active } : w)));
toast.success(wf.is_active ? '已停用' : '已启用');
} catch { toast.error('操作失败'); }
}, []);
const handleDelete = useCallback(async (id: number, name: string) => {
if (!window.confirm(`确定删除审批流程「${name}」?删除后不可恢复。`)) return;
try {
await workflowApi.delete(id);
setItems((prev) => prev.filter((w) => w.id !== id));
toast.success('已删除');
} catch { toast.error('删除失败'); }
}, []);
const setDefault = useCallback(async (id: number) => {
try {
await workflowApi.setDefault(id);
setItems((prev) => prev.map((w) => ({ ...w, is_default: w.id === id })));
toast.success('已设为默认');
} catch { toast.error('操作失败'); }
}, []);
const getUserName = useCallback(
(uid: number) => {
const u = users.find((u) => u.id === uid);
return u ? (u.display_name || u.username) : `#${uid}`;
},
[users]
);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
<p className="text-sm">{error}</p>
<Button variant="outline" onClick={fetchWorkflows} className="mt-3">重试</Button>
</div>
);
}
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-[var(--dg-text-primary)]">审批流程配置</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
为资源申请、应用申请、权限审批配置多级审批流程
</p>
</div>
<Button onClick={() => setEditorWorkflow(null)}>
<Plus className="w-4 h-4 mr-1.5" />新建流程
</Button>
</div>
{items.length === 0 ? (
<div className="dg-card p-12 text-center">
<GitBranch className="w-12 h-12 mx-auto mb-3 opacity-50 text-[var(--dg-text-muted)]" />
<p className="text-sm text-[var(--dg-text-muted)] mb-4">暂无审批流程</p>
<Button variant="outline" onClick={() => setEditorWorkflow(null)}>创建第一个流程</Button>
</div>
) : (
<>
{/* Workflow list */}
<div className="space-y-4">
{items.map((wf) => {
const isExpanded = expandedId === wf.id;
return (
<Card key={wf.id} className={`dg-card overflow-hidden ${!wf.is_active ? 'opacity-50' : ''}`}>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{wf.name}</h3>
{wf.is_default && <span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-400/15 text-yellow-400 flex items-center gap-1"><Star className="w-3 h-3" />默认</span>}
<span className={`text-[10px] px-1.5 py-0.5 rounded ${wf.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
{wf.is_active ? '启用' : '停用'}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">
{CATEGORY_LABELS[wf.category] || wf.category}
</span>
</div>
{wf.description && <p className="text-sm text-[var(--dg-text-secondary)] mb-1">{wf.description}</p>}
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
{wf.steps?.map((s, i) => (
<span key={i} className="flex items-center gap-1">
<span className={`text-[10px] px-1.5 py-0.5 rounded ${STEP_TYPE_COLORS[s.step_type] || 'bg-gray-500/15 text-gray-400 border-gray-500/30'}`}>
{STEP_TYPE_LABELS[s.step_type] || s.step_type}
</span>
{i < (wf.steps?.length || 0) - 1 && (
<span className="text-[var(--dg-text-muted)] text-[10px]">→</span>
)}
</span>
))}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-4">
<button onClick={() => setExpandedId(isExpanded ? null : wf.id)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] transition-colors">
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{!wf.is_default && (
<button onClick={() => setDefault(wf.id)}
className="p-1.5 rounded hover:bg-yellow-500/10 text-[var(--dg-text-muted)] hover:text-yellow-400 transition-colors" title="设为默认">
<Star className="w-4 h-4" />
</button>
)}
<button onClick={() => toggleActive(wf)}
className={`p-1.5 rounded transition-colors ${wf.is_active ? 'hover:bg-red-500/10 text-green-400 hover:text-red-400' : 'hover:bg-green-500/10 text-red-400 hover:text-green-400'}`}
title={wf.is_active ? '停用' : '启用'}>
<Play className={`w-4 h-4 ${!wf.is_active ? 'rotate-180' : ''}`} />
</button>
<button onClick={() => setEditorWorkflow(wf)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors" title="编辑">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(wf.id, wf.name)}
className="p-1.5 rounded hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors" title="删除">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-[var(--dg-border)] space-y-3">
{wf.steps?.map((s, i) => {
const ids = (() => { try { const p = JSON.parse(s.approver_ids || '[]'); return Array.isArray(p) ? p : []; } catch { return []; } })();
return (
<div key={i} className="flex items-start gap-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg">
<div className="w-6 h-6 rounded-full bg-[var(--dg-bg-tertiary)] flex items-center justify-center text-xs font-medium text-[var(--dg-text-secondary)] flex-shrink-0 mt-0.5">
{s.step_number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{s.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${STEP_TYPE_COLORS[s.step_type] || 'bg-gray-500/15 text-gray-400 border-gray-500/30'}`}>
{STEP_TYPE_LABELS[s.step_type] || s.step_type}
</span>
</div>
{ids.length > 0 && (
<div className="flex flex-wrap gap-1">
{ids.map((uid) => (
<span key={uid} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-blue-500/15 text-blue-400">
{getUserName(uid)}
</span>
))}
</div>
)}
{ids.length === 0 && s.step_type !== 'submit' && (
<span className="text-[11px] text-red-400">未关联审批人</span>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
</>
)}
{editorWorkflow !== undefined && (
<WorkflowEditorModal
workflow={editorWorkflow}
users={users}
onClose={() => setEditorWorkflow(undefined)}
onSaved={() => { setEditorWorkflow(undefined); fetchWorkflows(); }}
/>
)}
</div>
);
}
+4 -1
View File
@@ -7,11 +7,14 @@ import { isOnline, timeAgo, asList } from '../../components/agent/agentUtils';
export const userApi = { export const userApi = {
login: async (username: string, password: string) => { login: async (username: string, password: string) => {
return http.post<{ token: string; expires_in: number; user: unknown }>( return http.post<{ token: string; expires_in: number; user: unknown; permissions?: string[] }>(
'/auth/login', '/auth/login',
{ username, password } { username, password }
); );
}, },
getMyPermissions: async () => {
return http.get<{ permissions: string[]; is_admin: boolean }>('/user/permissions');
},
register: async (data: { username: string; password: string; nickname?: string; email?: string }) => { register: async (data: { username: string; password: string; nickname?: string; email?: string }) => {
return http.post('/auth/register', data); return http.post('/auth/register', data);
}, },
+26
View File
@@ -77,6 +77,32 @@ export const loginLogApi = {
}, },
}; };
// ==================== 审批流程配置 API(工作流定义) ====================
export const approvalWorkflowApi = {
list: async (params?: { category?: string }) => {
return http.get('/approval-workflows', params);
},
get: async (id: number | string) => {
return http.get(`/approval-workflows/${id}`);
},
create: async (data: any) => {
return http.post('/approval-workflows', data);
},
update: async (id: number | string, data: any) => {
return http.put(`/approval-workflows/${id}`, data);
},
delete: async (id: number | string) => {
return http.delete(`/approval-workflows/${id}`);
},
setDefault: async (id: number | string) => {
return http.put(`/approval-workflows/${id}/default`);
},
getSteps: async (id: number | string) => {
return http.get(`/approval-workflows/${id}/steps`);
},
};
// ==================== 审批流程 API ==================== // ==================== 审批流程 API ====================
export const approvalApi = { export const approvalApi = {
+8 -3
View File
@@ -19,9 +19,12 @@ interface UserState {
token: string | null; token: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
theme?: string; theme?: string;
/** 当前用户权限码列表(RBAC 按钮级控制);admin 为 ["*"];null 表示未加载 */
permissions: string[] | null;
setUser: (user: User | null) => void; setUser: (user: User | null) => void;
setToken: (token: string | null) => void; setToken: (token: string | null) => void;
login: (user: User, token: string) => void; setPermissions: (permissions: string[] | null) => void;
login: (user: User, token: string, permissions?: string[]) => void;
logout: () => void; logout: () => void;
} }
@@ -32,10 +35,12 @@ export const useUserStore = create<UserState>()(
token: null as string | null, token: null as string | null,
isAuthenticated: false, isAuthenticated: false,
theme: 'ocean-deep', theme: 'ocean-deep',
permissions: null as string[] | null,
setUser: (user) => set({ user }), setUser: (user) => set({ user }),
setToken: (token) => set({ token }), setToken: (token) => set({ token }),
login: (user, token) => set({ user, token, isAuthenticated: true }), setPermissions: (permissions) => set({ permissions }),
logout: () => { localStorage.removeItem('token'); set({ user: null as User | null, token: null as string | null, isAuthenticated: false }); }, login: (user, token, permissions) => set({ user, token, isAuthenticated: true, permissions: permissions ?? null }),
logout: () => { localStorage.removeItem('token'); set({ user: null as User | null, token: null as string | null, isAuthenticated: false, permissions: null }); },
}), }),
{ {
name: 'user-storage', name: 'user-storage',