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:
1 parent
77c234b060
commit
be2a377f01
30 files changed
+2328
-462
No files matched your search
@@ -187,7 +187,7 @@
|
|||||||
|----|:----:|
|
|----|:----:|
|
||||||
| JWT 认证 | ✅ |
|
| JWT 认证 | ✅ |
|
||||||
| RBAC 角色权限(后端) | ✅ |
|
| RBAC 角色权限(后端) | ✅ |
|
||||||
| RBAC 前端按钮级控制 | ❌ 缺失 |
|
| RBAC 前端按钮级控制 | ✅ `usePermission` hook + `<HasPermission>` + Sidebar 过滤 |
|
||||||
| SQL 注入防护 | ✅ 全参数化 |
|
| SQL 注入防护 | ✅ 全参数化 |
|
||||||
| XSS 防护 | ✅ React 默认 |
|
| XSS 防护 | ✅ React 默认 |
|
||||||
| HTTPS | ⚠️ 生产未强制 |
|
| HTTPS | ⚠️ 生产未强制 |
|
||||||
|
|||||||
+12
-8
@@ -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
@@ -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 核心端点通过
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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(¤tStep).Error; err != nil {
|
||||||
|
// 无步骤记录(旧数据兼容):直接通过
|
||||||
|
h.db.Model(&process).Updates(map[string]interface{}{
|
||||||
|
"status": "approved",
|
||||||
|
"approved_at": &now,
|
||||||
|
})
|
||||||
|
h.db.Preload("Template").Preload("ApplicantUser").Preload("Steps.ApproverUser").First(&process, process.ID)
|
||||||
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "审批通过", Data: process})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := h.db.Begin()
|
||||||
|
|
||||||
|
// 更新当前步骤为通过
|
||||||
|
tx.Model(¤tStep).Updates(map[string]interface{}{
|
||||||
|
"status": "approved",
|
||||||
|
"approver": approverID,
|
||||||
|
"comment": req.Comment,
|
||||||
|
"approved_at": &now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 推进到下一步 或 标记全部通过
|
||||||
|
if process.CurrentStep >= process.TotalSteps {
|
||||||
|
tx.Model(&process).Updates(map[string]interface{}{
|
||||||
|
"status": "approved",
|
||||||
|
"approved_at": &now,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
tx.Model(&process).Update("current_step", process.CurrentStep+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit()
|
||||||
|
|
||||||
|
h.db.Preload("Template").Preload("ApplicantUser").Preload("Steps.ApproverUser").First(&process, process.ID)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.Response{
|
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: "已拒绝",
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 更新当前登录用户的信息
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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': '智能助手',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in new issue
Block a user