feat: 流水线三级权限体系(手动多选+通配符授权)+ 菜单分级 + SSE 认证修复
权限模型(借鉴 Jenkins Role-Strategy 与云效,默认拒绝): - 三级权限:查看(流水线+成功/失败日志)/ 发布(+构建、批量发布、重试)/ 管理(+配置、成员);admin 全局豁免 - 权限组(管理员配置):默认手动多选流水线(免正则,操作友好);高级模式仅 * 通配符(cex-*) - 单流水线成员直授权作为例外补充;AI 批量发布逐 job 校验,越权明确拒绝 后端: - 新增 PipelineRole/PipelineMember 表与 /api/pipeline-roles、/api/pipelines/:id/members CRUD - RequirePipelineAccess/RequireRunAccess 中间件挂全流水线与运行路由;列表按可见性过滤并附 accessLevel/canRetry - /api/me 会话接口;logs/stream SSE 支持 ?token= 认证(原先完全免鉴权)并校验查看权限 - 凭证/主机/集群/构建节点/工具/Webhook/Jenkins/GitOps/插件/设置全部 AdminOnly;触发人以会话身份落库 前端: - useSession 会话 hook;Sidebar 菜单分级(资源/集成/系统管理 admin-only);admin 路由守卫 - 按钮级权限:执行/配置/删除/保存/重试按等级显隐;新页「流水线权限」与流水线成员弹窗(纯点选) - 登录页记住密码;GUIDE/API 文档补权限章节
This commit is contained in:
1 parent
7090c8efc2
commit
9103b13510
27 files changed
+1690
-190
No files matched your search
+29
-13
@@ -37,25 +37,41 @@ LDAP 用户名在目录验证通过后自动建号/同步(JIT);错误统
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/api/pipelines` | GET | 列表(每条附 `lastRun` 最近运行) |
|
||||
| `/api/pipelines` | POST | 创建(name 必填;deployType: host/kubernetes 值为 `k8s`/gitops) |
|
||||
| `/api/pipelines/:id` | GET | 详情(pipeline + nodes + edges + lastRun) |
|
||||
| `/api/pipelines/:id` | PUT | 更新(白名单字段,含触发器/变量/缓存) |
|
||||
| `/api/pipelines/:id` | DELETE | 删除(级联删节点连线) |
|
||||
| `/api/pipelines/:id/nodes` | PUT | 全量保存画布节点 |
|
||||
| `/api/pipelines/:id/edges` | PUT | 全量保存画布连线 |
|
||||
| `/api/pipelines/:id/runs` | POST | 触发运行(body 可带 branch/env/triggerType) |
|
||||
| `/api/pipelines` | GET | 列表(每条附 `lastRun` 与 `accessLevel`;普通用户仅返回被授权的) |
|
||||
| `/api/pipelines` | POST | 创建(**管理员**) |
|
||||
| `/api/pipelines/:id` | GET | 详情(pipeline + nodes + edges + lastRun + `accessLevel`;需「查看」) |
|
||||
| `/api/pipelines/:id` | PUT | 更新(需「管理」) |
|
||||
| `/api/pipelines/:id` | DELETE | 删除(**管理员**;级联删节点连线) |
|
||||
| `/api/pipelines/:id/nodes` | PUT | 全量保存画布节点(需「管理」) |
|
||||
| `/api/pipelines/:id/edges` | PUT | 全量保存画布连线(需「管理」) |
|
||||
| `/api/pipelines/:id/runs` | POST | 触发运行(需「发布」;body 可带 branch/env/triggerType) |
|
||||
|
||||
`type=jenkins` 且已关联服务器的流水线触发时走真实 Jenkins 引擎(队列→构建→增量日志回传)。
|
||||
|
||||
## 流水线权限
|
||||
|
||||
三级等级:`viewer` 查看 / `operator` 发布 / `owner` 管理;管理员全局豁免。未被授权的普通用户默认不可见任何流水线。
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/api/me` | GET | 当前会话(username/role/isAdmin) |
|
||||
| `/api/pipeline-roles` | GET | 权限组列表(**管理员**;附 matchedPipelines 匹配数) |
|
||||
| `/api/pipeline-roles` | POST | 创建(name/scopeMode=manual|pattern/pattern/pipelineIds/level/members;pattern 仅支持 `*` 通配符) |
|
||||
| `/api/pipeline-roles/:id` | PUT/DELETE | 更新 / 删除(**管理员**) |
|
||||
| `/api/pipelines/:id/members` | GET | 单流水线直接成员 + 命中的权限组(需「查看」) |
|
||||
| `/api/pipelines/:id/members` | POST | 添加/更新成员等级(需「管理」;body: username/level) |
|
||||
| `/api/pipelines/:id/members/:mid` | DELETE | 移除直接授权(需「管理」) |
|
||||
|
||||
资源(主机/集群/凭证/构建节点/工具/Webhook)、Jenkins 服务器、GitOps、插件与系统设置接口均要求**管理员**。
|
||||
|
||||
## 运行记录
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/api/runs` | GET | 运行列表(`?pipelineId=&status=`) |
|
||||
| `/api/runs/:runId` | GET | 详情(run + stages + logs;失败含 AI `analysis`) |
|
||||
| `/api/runs/:runId/logs/stream` | GET | SSE 实时日志(免鉴权;事件 connected/log,历史先回放) |
|
||||
| `/api/runs/:runId/retry` | POST | 以同分支重新触发 |
|
||||
| `/api/runs` | GET | 运行列表(`?pipelineId=&status=`;普通用户仅返回被授权流水线的,每条附 `canRetry`) |
|
||||
| `/api/runs/:runId` | GET | 详情(run + stages + logs;失败含 AI `analysis`;需「查看」) |
|
||||
| `/api/runs/:runId/logs/stream` | GET | SSE 实时日志(需登录:`?token=` 传会话 token 且需「查看」;事件 connected/log) |
|
||||
| `/api/runs/:runId/retry` | POST | 以同分支重新触发(需「发布」) |
|
||||
|
||||
## AI 与批量发布
|
||||
|
||||
@@ -133,7 +149,7 @@ curl -X POST http://localhost:9527/api/ai/release \
|
||||
|---|---|
|
||||
| 400 | 参数错误 / 未启用对应功能 |
|
||||
| 401 | 未登录 / token 过期 / 用户名或密码错误 |
|
||||
| 403 | 需要 admin 角色 |
|
||||
| 403 | 需要 admin 角色 / 无该流水线的对应权限等级 |
|
||||
| 404 | 资源不存在 |
|
||||
| 409 | 名称重复等冲突 |
|
||||
|
||||
|
||||
@@ -119,6 +119,33 @@ cex-agent-admin
|
||||
- 凭证内容 AES 加密存储、只写不回显,编辑留空=不修改
|
||||
- SSH 凭证关联构建节点后即可远程磁盘测试
|
||||
|
||||
## 流水线权限
|
||||
|
||||
**是什么**:按流水线控制「谁能看、谁能发布、谁能改」。未被授权的普通用户看不到任何流水线与发布记录(默认拒绝);管理员始终拥有全部权限。
|
||||
|
||||
**三级权限**:
|
||||
|
||||
| 等级 | 能做什么 |
|
||||
|---|---|
|
||||
| 查看 | 看流水线、发布记录、发布成功/失败日志(只读,不能构建) |
|
||||
| 发布 | 查看 + 触发构建、批量发布、失败重试 |
|
||||
| 管理 | 发布 + 编辑流水线配置、管理该流水线的成员授权 |
|
||||
|
||||
**怎么授权(管理员,系统 → 流水线权限)**:
|
||||
- 新建权限组 → **选择流水线**:直接勾选要授权的流水线(推荐,全程点选)
|
||||
- 或 **名称匹配(高级)**:只写 `cex-*` 这类通配符(`*` 代表任意字符,无需正则),一次授权整批,之后新建的同名流水线自动纳入
|
||||
- 选好权限等级,勾选成员,保存即生效
|
||||
- 也可以在单条流水线里:编辑器顶栏「成员」按钮,给个别人单独授权(作为权限组的例外补充)
|
||||
|
||||
**普通用户能看什么**:
|
||||
- 菜单只剩「发布」组(流水线/批量发布/发布记录)和「文档中心」;资源、集成、GitOps、系统管理类菜单全部隐藏(直接输 URL 也会被拦回)
|
||||
- 流水线列表只显示被授权的;按钮按等级显隐:查看级只有只读入口,发布级出现「执行/保存并运行/重试」,管理级才有「保存/成员」
|
||||
- 新建/删除流水线、凭证/主机/集群/构建节点等资源管理仅管理员
|
||||
|
||||
**技巧**:
|
||||
- AI 助手批量发布同样受控:对未授权的 Job 会在结果里明确标注「无该流水线的执行权限」
|
||||
- 构建日志 SSE 已要求登录(`?token=` 传递会话),未登录直接访问返回 401
|
||||
|
||||
## 常见问题
|
||||
|
||||
**初始密码在哪**:服务器 `data/initial-admin-password.txt` 或 `grep -A6 "已生成管理员账号" logs/flowstack.log`
|
||||
|
||||
@@ -36,6 +36,7 @@ func Init() error {
|
||||
|
||||
if err := DB.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.PipelineRole{}, &model.PipelineMember{},
|
||||
&model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{},
|
||||
&model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{},
|
||||
&model.Application{}, &model.Plugin{},
|
||||
|
||||
@@ -362,6 +362,7 @@ func AiRelease(hub *Hub) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
autoCreate := body.AutoCreate == nil || *body.AutoCreate
|
||||
username, isAdmin := currentUser(c)
|
||||
|
||||
candidates := allCandidates()
|
||||
type triggered struct {
|
||||
@@ -378,12 +379,24 @@ func AiRelease(hub *Hub) gin.HandlerFunc {
|
||||
var failList []failed
|
||||
|
||||
for _, name := range body.Jobs {
|
||||
// 权限预检(operator 及以上):已存在的流水线按完整权限判定;
|
||||
// 未导入的 Jenkins Job 按正则角色命中判定,且必须先于自动创建——避免建出请求者自己都无权可见的流水线
|
||||
var existing model.Pipeline
|
||||
if database.DB.Where("name = ? OR jenkins_job_name = ?", name, name).First(&existing).Error == nil {
|
||||
if pipelineAccessLevel(username, isAdmin, &existing) < LevelRank(LevelOperator) {
|
||||
failList = append(failList, failed{Job: name, Reason: "无该流水线的执行权限"})
|
||||
continue
|
||||
}
|
||||
} else if !isAdmin && roleLevelForName(username, name) < LevelRank(LevelOperator) {
|
||||
failList = append(failList, failed{Job: name, Reason: "无该流水线的执行权限(权限组未匹配该名称)"})
|
||||
continue
|
||||
}
|
||||
p, created, err := resolvePipeline(name, candidates, autoCreate, body.JenkinsServerID)
|
||||
if err != nil {
|
||||
failList = append(failList, failed{Job: name, Reason: err.Error()})
|
||||
continue
|
||||
}
|
||||
run, err := triggerPipelineRun(hub, *p, body.Branch, body.Env, "admin", "batch")
|
||||
run, err := triggerPipelineRun(hub, *p, body.Branch, body.Env, username, "batch")
|
||||
if err != nil {
|
||||
failList = append(failList, failed{Job: name, Reason: "触发失败: " + err.Error()})
|
||||
continue
|
||||
|
||||
@@ -68,23 +68,28 @@ func VerifyToken(token string) (string, error) {
|
||||
return parts[0], nil
|
||||
}
|
||||
|
||||
// AuthMiddleware 会话鉴权:仅保护 /api/*(豁免登录/入站 Webhook/SSE 日志流);SPA 与静态资源放行
|
||||
// AuthMiddleware 会话鉴权:仅保护 /api/*(豁免登录/入站 Webhook);SPA 与静态资源放行。
|
||||
// SSE 日志流(*/logs/stream)的 EventSource 无法携带 Authorization 头,额外接受 ?token= 查询参数。
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
p := c.Request.URL.Path
|
||||
if p == "/health" || !strings.HasPrefix(p, "/api/") ||
|
||||
p == "/api/auth/login" ||
|
||||
strings.HasPrefix(p, "/api/hooks/") ||
|
||||
strings.HasSuffix(p, "/logs/stream") {
|
||||
strings.HasPrefix(p, "/api/hooks/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
auth := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
token := ""
|
||||
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
token = strings.TrimPrefix(auth, "Bearer ")
|
||||
} else if strings.HasSuffix(p, "/logs/stream") {
|
||||
token = c.Query("token")
|
||||
}
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
username, err := VerifyToken(strings.TrimPrefix(auth, "Bearer "))
|
||||
username, err := VerifyToken(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效,请重新登录"})
|
||||
return
|
||||
@@ -100,6 +105,15 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Me GET /api/me —— 当前会话信息(前端菜单分级与按钮权限依据)
|
||||
func Me(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": c.GetString("username"),
|
||||
"role": c.GetString("role"),
|
||||
"isAdmin": c.GetString("role") == "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// AdminOnly 管理员专用接口守卫
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// 三级流水线权限:viewer 查看 / operator 执行 / owner 管理;admin 全局豁免(等级 4)
|
||||
const (
|
||||
LevelViewer = "viewer"
|
||||
LevelOperator = "operator"
|
||||
LevelOwner = "owner"
|
||||
|
||||
adminAccessLevel = 4
|
||||
)
|
||||
|
||||
// LevelRank 数值化权限等级,用于比较
|
||||
func LevelRank(level string) int {
|
||||
switch level {
|
||||
case LevelOwner:
|
||||
return 3
|
||||
case LevelOperator:
|
||||
return 2
|
||||
case LevelViewer:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// currentUser 从 AuthMiddleware 注入的上下文取当前用户
|
||||
func currentUser(c *gin.Context) (username string, isAdmin bool) {
|
||||
return c.GetString("username"), c.GetString("role") == "admin"
|
||||
}
|
||||
|
||||
func memberInList(members []string, username string) bool {
|
||||
for _, m := range members {
|
||||
if m == username {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// globToRegexp 通配符(* 任意字符、? 单字符)转正则,其余字符按字面量——操作员无需正则知识
|
||||
func globToRegexp(glob string) *regexp.Regexp {
|
||||
expr := regexp.QuoteMeta(glob)
|
||||
expr = strings.ReplaceAll(expr, `\*`, `.*`)
|
||||
expr = strings.ReplaceAll(expr, `\?`, `.`)
|
||||
return regexp.MustCompile(expr)
|
||||
}
|
||||
|
||||
// roleMatchPipeline 权限组是否覆盖某流水线:manual 按多选 ID、pattern 按通配符匹配名称
|
||||
func roleMatchPipeline(role *model.PipelineRole, p *model.Pipeline) bool {
|
||||
if role.ScopeMode == "pattern" {
|
||||
if role.Pattern == "" {
|
||||
return false
|
||||
}
|
||||
re := globToRegexp(role.Pattern)
|
||||
return re.MatchString(p.Name) || (p.JenkinsJobName != "" && re.MatchString(p.JenkinsJobName))
|
||||
}
|
||||
for _, id := range role.PipelineIDs {
|
||||
if id == p.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// roleLevelForPipeline 用户通过权限组对某流水线获得的最高等级(manual + pattern 一并计算)
|
||||
func roleLevelForPipeline(username string, p *model.Pipeline) int {
|
||||
if username == "" {
|
||||
return 0
|
||||
}
|
||||
var roles []model.PipelineRole
|
||||
database.DB.Find(&roles)
|
||||
best := 0
|
||||
for i := range roles {
|
||||
role := &roles[i]
|
||||
if !memberInList(role.Members, username) {
|
||||
continue
|
||||
}
|
||||
if roleMatchPipeline(role, p) {
|
||||
if r := LevelRank(role.Level); r > best {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// roleLevelForName 按名称预检(AI 自动创建前):只有 pattern 模式能命中尚未创建的流水线
|
||||
func roleLevelForName(username string, name string) int {
|
||||
if username == "" {
|
||||
return 0
|
||||
}
|
||||
var roles []model.PipelineRole
|
||||
database.DB.Find(&roles)
|
||||
best := 0
|
||||
for _, role := range roles {
|
||||
if !memberInList(role.Members, username) || role.ScopeMode != "pattern" || role.Pattern == "" {
|
||||
continue
|
||||
}
|
||||
if globToRegexp(role.Pattern).MatchString(name) {
|
||||
if r := LevelRank(role.Level); r > best {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// pipelineAccessLevel 用户对某流水线的最高权限等级:
|
||||
// admin 豁免 → 单流水线直接成员 → 权限组命中(manual/pattern,取最大值)
|
||||
func pipelineAccessLevel(username string, isAdmin bool, p *model.Pipeline) int {
|
||||
if isAdmin {
|
||||
return adminAccessLevel
|
||||
}
|
||||
best := 0
|
||||
var member model.PipelineMember
|
||||
if err := database.DB.Where("pipeline_id = ? AND username = ?", p.ID, username).First(&member).Error; err == nil {
|
||||
best = LevelRank(member.Level)
|
||||
}
|
||||
if r := roleLevelForPipeline(username, p); r > best {
|
||||
best = r
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// AccessiblePipelineIDs 列表过滤用:直接成员 ∪ 权限组命中的流水线 ID。
|
||||
// 第二个返回值表示是否需要过滤:admin 返回 (nil, false) 全量可见;
|
||||
// 普通用户返回 (ids, true),ids 可为空 = 无任何可见流水线
|
||||
func AccessiblePipelineIDs(username string, isAdmin bool) ([]uint, bool) {
|
||||
if isAdmin {
|
||||
return nil, false
|
||||
}
|
||||
if username == "" {
|
||||
return []uint{}, true
|
||||
}
|
||||
ids := map[uint]bool{}
|
||||
var memberIDs []uint
|
||||
database.DB.Model(&model.PipelineMember{}).Where("username = ?", username).Pluck("pipeline_id", &memberIDs)
|
||||
for _, id := range memberIDs {
|
||||
ids[id] = true
|
||||
}
|
||||
|
||||
var roles []model.PipelineRole
|
||||
database.DB.Find(&roles)
|
||||
patterns := make([]*regexp.Regexp, 0, len(roles))
|
||||
for i := range roles {
|
||||
role := &roles[i]
|
||||
if !memberInList(role.Members, username) {
|
||||
continue
|
||||
}
|
||||
if role.ScopeMode == "pattern" && role.Pattern != "" {
|
||||
patterns = append(patterns, globToRegexp(role.Pattern))
|
||||
} else {
|
||||
for _, id := range role.PipelineIDs {
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(patterns) > 0 {
|
||||
var pipelines []model.Pipeline
|
||||
database.DB.Select("id", "name", "jenkins_job_name").Find(&pipelines)
|
||||
for _, p := range pipelines {
|
||||
for _, re := range patterns {
|
||||
if re.MatchString(p.Name) || (p.JenkinsJobName != "" && re.MatchString(p.JenkinsJobName)) {
|
||||
ids[p.ID] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]uint, 0, len(ids))
|
||||
for id := range ids {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// RequirePipelineAccess 流水线级权限守卫:解析 :id 后按最低等级校验,通过后注入 pipeline
|
||||
func RequirePipelineAccess(minLevel int) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var p model.Pipeline
|
||||
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
username, isAdmin := currentUser(c)
|
||||
if pipelineAccessLevel(username, isAdmin, &p) < minLevel {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权访问该流水线"})
|
||||
return
|
||||
}
|
||||
c.Set("pipeline", &p)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRunAccess 运行级权限守卫:runId → run → pipeline 再校验(防 runId 绕过流水线权限)
|
||||
func RequireRunAccess(minLevel int) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var run model.PipelineRun
|
||||
if err := database.DB.Where("run_id = ?", c.Param("runId")).First(&run).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "run not found"})
|
||||
return
|
||||
}
|
||||
var p model.Pipeline
|
||||
if err := database.DB.First(&p, run.PipelineID).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
username, isAdmin := currentUser(c)
|
||||
if pipelineAccessLevel(username, isAdmin, &p) < minLevel {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权访问该流水线的运行记录"})
|
||||
return
|
||||
}
|
||||
c.Set("pipeline", &p)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 流水线权限组(默认手动多选流水线;高级模式用 * 通配符匹配名称)—— 仅管理员
|
||||
// ============================================================
|
||||
|
||||
type roleBody struct {
|
||||
Name string `json:"name"`
|
||||
ScopeMode string `json:"scopeMode"` // manual 手动多选 | pattern 名称匹配
|
||||
Pattern string `json:"pattern"` // scopeMode=pattern 时生效,支持 * 通配符
|
||||
PipelineIDs []uint `json:"pipelineIds"`
|
||||
Level string `json:"level"`
|
||||
Members []string `json:"members"`
|
||||
}
|
||||
|
||||
func validateRoleBody(b *roleBody) string {
|
||||
if b.Name == "" {
|
||||
return "名称不能为空"
|
||||
}
|
||||
if b.ScopeMode == "" {
|
||||
b.ScopeMode = "manual"
|
||||
}
|
||||
if b.ScopeMode != "manual" && b.ScopeMode != "pattern" {
|
||||
return "授权范围类型不合法"
|
||||
}
|
||||
if b.ScopeMode == "pattern" && b.Pattern == "" {
|
||||
return "名称匹配需要填写匹配规则,如 cex-*"
|
||||
}
|
||||
if b.ScopeMode == "manual" && len(b.PipelineIDs) == 0 {
|
||||
return "请至少选择一条流水线"
|
||||
}
|
||||
if LevelRank(b.Level) == 0 {
|
||||
return "请选择权限等级"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListPipelineRoles GET /api/pipeline-roles —— 附匹配流水线数
|
||||
func ListPipelineRoles(c *gin.Context) {
|
||||
var roles []model.PipelineRole
|
||||
database.DB.Order("id").Find(&roles)
|
||||
var pipelines []model.Pipeline
|
||||
database.DB.Select("id", "name", "jenkins_job_name").Find(&pipelines)
|
||||
|
||||
type roleWithCount struct {
|
||||
model.PipelineRole
|
||||
MatchedPipelines int `json:"matchedPipelines"`
|
||||
}
|
||||
out := make([]roleWithCount, 0, len(roles))
|
||||
for i := range roles {
|
||||
role := &roles[i]
|
||||
n := 0
|
||||
for j := range pipelines {
|
||||
if roleMatchPipeline(role, &pipelines[j]) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
out = append(out, roleWithCount{PipelineRole: *role, MatchedPipelines: n})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// CreatePipelineRole POST /api/pipeline-roles
|
||||
func CreatePipelineRole(c *gin.Context) {
|
||||
var body roleBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if msg := validateRoleBody(&body); msg != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
role := model.PipelineRole{
|
||||
Name: body.Name, ScopeMode: body.ScopeMode, Pattern: body.Pattern,
|
||||
PipelineIDs: body.PipelineIDs, Level: body.Level, Members: body.Members,
|
||||
}
|
||||
if role.PipelineIDs == nil {
|
||||
role.PipelineIDs = []uint{}
|
||||
}
|
||||
if role.Members == nil {
|
||||
role.Members = []string{}
|
||||
}
|
||||
if err := database.DB.Create(&role).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败(名称可能已存在): " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, role)
|
||||
}
|
||||
|
||||
// UpdatePipelineRole PUT /api/pipeline-roles/:id
|
||||
func UpdatePipelineRole(c *gin.Context) {
|
||||
var role model.PipelineRole
|
||||
if err := database.DB.First(&role, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "权限组不存在"})
|
||||
return
|
||||
}
|
||||
var body roleBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if msg := validateRoleBody(&body); msg != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
role.Name, role.ScopeMode, role.Pattern = body.Name, body.ScopeMode, body.Pattern
|
||||
role.PipelineIDs, role.Level, role.Members = body.PipelineIDs, body.Level, body.Members
|
||||
if role.PipelineIDs == nil {
|
||||
role.PipelineIDs = []uint{}
|
||||
}
|
||||
if role.Members == nil {
|
||||
role.Members = []string{}
|
||||
}
|
||||
if err := database.DB.Save(&role).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, role)
|
||||
}
|
||||
|
||||
// DeletePipelineRole DELETE /api/pipeline-roles/:id
|
||||
func DeletePipelineRole(c *gin.Context) {
|
||||
database.DB.Delete(&model.PipelineRole{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单流水线成员(云效式直接授权)—— owner 或 admin
|
||||
// ============================================================
|
||||
|
||||
// ListPipelineMembers GET /api/pipelines/:id/members —— 直接成员 + 命中的权限组(只读参考)
|
||||
func ListPipelineMembers(c *gin.Context) {
|
||||
p, _ := c.Get("pipeline")
|
||||
pipeline := p.(*model.Pipeline)
|
||||
|
||||
var members []model.PipelineMember
|
||||
database.DB.Where("pipeline_id = ?", pipeline.ID).Order("id").Find(&members)
|
||||
|
||||
var roles []model.PipelineRole
|
||||
database.DB.Find(&roles)
|
||||
type matchedRole struct {
|
||||
Name string `json:"name"`
|
||||
Level string `json:"level"`
|
||||
Pattern string `json:"pattern"`
|
||||
Mode string `json:"mode"`
|
||||
Members []string `json:"members"`
|
||||
}
|
||||
matched := make([]matchedRole, 0)
|
||||
for i := range roles {
|
||||
role := &roles[i]
|
||||
if roleMatchPipeline(role, pipeline) {
|
||||
matched = append(matched, matchedRole{Name: role.Name, Level: role.Level, Pattern: role.Pattern, Mode: role.ScopeMode, Members: role.Members})
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"members": members, "matchedRoles": matched})
|
||||
}
|
||||
|
||||
// AddPipelineMember POST /api/pipelines/:id/members —— 已存在则更新等级
|
||||
func AddPipelineMember(c *gin.Context) {
|
||||
p, _ := c.Get("pipeline")
|
||||
pipeline := p.(*model.Pipeline)
|
||||
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Level string `json:"level"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择用户"})
|
||||
return
|
||||
}
|
||||
if LevelRank(body.Level) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择权限等级"})
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
if err := database.DB.Where("username = ?", body.Username).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户不存在(LDAP 用户请先导入或登录一次)"})
|
||||
return
|
||||
}
|
||||
|
||||
var member model.PipelineMember
|
||||
if err := database.DB.Where("pipeline_id = ? AND username = ?", pipeline.ID, body.Username).First(&member).Error; err == nil {
|
||||
member.Level = body.Level
|
||||
database.DB.Save(&member)
|
||||
c.JSON(http.StatusOK, member)
|
||||
return
|
||||
}
|
||||
member = model.PipelineMember{PipelineID: pipeline.ID, Username: body.Username, Level: body.Level}
|
||||
if err := database.DB.Create(&member).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, member)
|
||||
}
|
||||
|
||||
// DeletePipelineMember DELETE /api/pipelines/:id/members/:mid
|
||||
func DeletePipelineMember(c *gin.Context) {
|
||||
p, _ := c.Get("pipeline")
|
||||
pipeline := p.(*model.Pipeline)
|
||||
database.DB.Where("pipeline_id = ? AND id = ?", pipeline.ID, c.Param("mid")).Delete(&model.PipelineMember{})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -14,10 +14,17 @@ import (
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息
|
||||
// ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息与当前用户权限等级
|
||||
func ListPipelines(c *gin.Context) {
|
||||
username, isAdmin := currentUser(c)
|
||||
ids, filter := AccessiblePipelineIDs(username, isAdmin)
|
||||
|
||||
q := database.DB.Order("updated_at DESC")
|
||||
if filter {
|
||||
q = q.Where("id IN ?", ids)
|
||||
}
|
||||
var list []model.Pipeline
|
||||
database.DB.Order("updated_at DESC").Find(&list)
|
||||
q.Find(&list)
|
||||
|
||||
// 每流水线最近一次 run(一次查询取全表后内存分组,数据量小;jianmu 用反规范化表,此处无需)
|
||||
var runs []model.PipelineRun
|
||||
@@ -31,11 +38,12 @@ func ListPipelines(c *gin.Context) {
|
||||
|
||||
type pipelineWithLastRun struct {
|
||||
model.Pipeline
|
||||
LastRun *model.PipelineRun `json:"lastRun"`
|
||||
LastRun *model.PipelineRun `json:"lastRun"`
|
||||
AccessLevel int `json:"accessLevel"` // 1查看 2执行 3管理 4管理员
|
||||
}
|
||||
result := make([]pipelineWithLastRun, 0, len(list))
|
||||
for _, p := range list {
|
||||
item := pipelineWithLastRun{Pipeline: p}
|
||||
item := pipelineWithLastRun{Pipeline: p, AccessLevel: pipelineAccessLevel(username, isAdmin, &p)}
|
||||
if r, ok := lastByPipeline[p.ID]; ok {
|
||||
item.LastRun = &r
|
||||
}
|
||||
@@ -57,8 +65,10 @@ func GetPipelineDetail(c *gin.Context) {
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
||||
var lastRun model.PipelineRun
|
||||
hasRun := database.DB.Where("pipeline_id = ?", p.ID).Order("started_at DESC").First(&lastRun).Error == nil
|
||||
username, isAdmin := currentUser(c)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pipeline": p, "nodes": nodes, "edges": edges,
|
||||
"accessLevel": pipelineAccessLevel(username, isAdmin, &p),
|
||||
"lastRun": func() *model.PipelineRun {
|
||||
if hasRun {
|
||||
return &lastRun
|
||||
|
||||
@@ -29,7 +29,12 @@ func CreateRun(hub *Hub) gin.HandlerFunc {
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
|
||||
run, err := triggerPipelineRun(hub, p, body.Branch, body.Env, body.TriggerBy, body.TriggerType)
|
||||
// 触发人以会话身份为准(服务端可信,覆盖前端传入)
|
||||
triggerBy := c.GetString("username")
|
||||
if triggerBy == "" {
|
||||
triggerBy = body.TriggerBy
|
||||
}
|
||||
run, err := triggerPipelineRun(hub, p, body.Branch, body.Env, triggerBy, body.TriggerType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -222,9 +227,15 @@ func stageScript(st *model.RunStage) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// ListRuns GET /api/runs?pipelineId=&status=
|
||||
// ListRuns GET /api/runs?pipelineId=&status= —— 按流水线可见性过滤,附 canRetry
|
||||
func ListRuns(c *gin.Context) {
|
||||
username, isAdmin := currentUser(c)
|
||||
ids, filter := AccessiblePipelineIDs(username, isAdmin)
|
||||
|
||||
q := database.DB.Model(&model.PipelineRun{}).Order("started_at DESC").Limit(100)
|
||||
if filter {
|
||||
q = q.Where("pipeline_id IN ?", ids)
|
||||
}
|
||||
if v := c.Query("pipelineId"); v != "" {
|
||||
q = q.Where("pipeline_id = ?", v)
|
||||
}
|
||||
@@ -233,7 +244,25 @@ func ListRuns(c *gin.Context) {
|
||||
}
|
||||
var runs []model.PipelineRun
|
||||
q.Find(&runs)
|
||||
c.JSON(http.StatusOK, runs)
|
||||
|
||||
type runWithPerm struct {
|
||||
model.PipelineRun
|
||||
CanRetry bool `json:"canRetry"`
|
||||
}
|
||||
levelCache := map[uint]int{}
|
||||
out := make([]runWithPerm, 0, len(runs))
|
||||
for _, r := range runs {
|
||||
lv, ok := levelCache[r.PipelineID]
|
||||
if !ok {
|
||||
var p model.Pipeline
|
||||
if database.DB.First(&p, r.PipelineID).Error == nil {
|
||||
lv = pipelineAccessLevel(username, isAdmin, &p)
|
||||
}
|
||||
levelCache[r.PipelineID] = lv
|
||||
}
|
||||
out = append(out, runWithPerm{PipelineRun: r, CanRetry: lv >= LevelRank(LevelOperator)})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetRun GET /api/runs/:runId
|
||||
@@ -316,7 +345,7 @@ func RetryRun(hub *Hub) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
run, err := triggerPipelineRun(hub, p, prev.Branch, prev.Env, "admin", "manual")
|
||||
run, err := triggerPipelineRun(hub, p, prev.Branch, prev.Env, c.GetString("username"), "manual")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -40,6 +40,30 @@ type Pipeline struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PipelineRole 流水线权限角色:默认手动多选流水线(scopeMode=manual),
|
||||
// 高级模式用通配符 Pattern(scopeMode=pattern,如 cex-*,免正则)批量匹配名称
|
||||
type PipelineRole struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex" json:"name"` // 如 cex 发布组
|
||||
ScopeMode string `gorm:"default:manual" json:"scopeMode"` // manual 手动多选 | pattern 名称匹配
|
||||
Pattern string `json:"pattern"` // scopeMode=pattern 时生效,支持 * 通配符
|
||||
PipelineIDs []uint `gorm:"serializer:json" json:"pipelineIds"` // scopeMode=manual 时生效
|
||||
Level string `gorm:"default:viewer" json:"level"` // viewer | operator | owner
|
||||
Members []string `gorm:"serializer:json" json:"members"` // 授权用户名列表
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PipelineMember 单流水线直接授权(云效式成员管理,作正则角色的例外补充)
|
||||
type PipelineMember struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PipelineID uint `gorm:"uniqueIndex:idx_pipeline_member" json:"pipelineId"`
|
||||
Username string `gorm:"uniqueIndex:idx_pipeline_member" json:"username"`
|
||||
Level string `gorm:"default:viewer" json:"level"` // viewer | operator | owner
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PipelineNode 画布节点(JSON 字段名与前端 React Flow 对齐)
|
||||
type PipelineNode struct {
|
||||
ID uint `gorm:"primaryKey" json:"dbId"`
|
||||
|
||||
+107
-68
@@ -59,101 +59,140 @@ func main() {
|
||||
{
|
||||
api.POST("/auth/login", handler.Login)
|
||||
api.POST("/auth/logout", func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) })
|
||||
api.GET("/me", handler.Me)
|
||||
|
||||
// 用户管理(管理员)
|
||||
// 用户管理(列表登录即可读——权限成员选择器需要;增删改仅管理员)
|
||||
api.GET("/users", handler.ListUsers)
|
||||
users := api.Group("/users", handler.AdminOnly())
|
||||
{
|
||||
users.GET("", handler.ListUsers)
|
||||
users.POST("", handler.CreateUser)
|
||||
users.PUT("/:id", handler.UpdateUser)
|
||||
users.DELETE("/:id", handler.DeleteUser)
|
||||
users.POST("/:id/reset-password", handler.ResetPassword)
|
||||
users.POST("/ldap-import", handler.LdapImport)
|
||||
}
|
||||
// 流水线权限角色(管理员;Jenkins Item-role 正则风格)
|
||||
pipelineRoles := api.Group("/pipeline-roles", handler.AdminOnly())
|
||||
{
|
||||
pipelineRoles.GET("", handler.ListPipelineRoles)
|
||||
pipelineRoles.POST("", handler.CreatePipelineRole)
|
||||
pipelineRoles.PUT("/:id", handler.UpdatePipelineRole)
|
||||
pipelineRoles.DELETE("/:id", handler.DeletePipelineRole)
|
||||
}
|
||||
api.POST("/ldap/users", handler.AdminOnly(), handler.LdapListUsers)
|
||||
api.POST("/settings/ldap/test", handler.AdminOnly(), handler.TestLDAP)
|
||||
api.GET("/docs", handler.GetDocs(docsFS))
|
||||
|
||||
api.GET("/summary", handler.GetSummary)
|
||||
api.GET("/settings", handler.GetSettings)
|
||||
api.PUT("/settings", handler.UpdateSettings)
|
||||
api.POST("/settings/test-llm", handler.TestLLM)
|
||||
api.POST("/settings/test-lark", handler.TestLark)
|
||||
api.GET("/settings", handler.AdminOnly(), handler.GetSettings)
|
||||
api.PUT("/settings", handler.AdminOnly(), handler.UpdateSettings)
|
||||
api.POST("/settings/test-llm", handler.AdminOnly(), handler.TestLLM)
|
||||
api.POST("/settings/test-lark", handler.AdminOnly(), handler.TestLark)
|
||||
|
||||
api.POST("/ai/chat", handler.AiChat)
|
||||
api.POST("/ai/chat/stream", handler.AiChatStream)
|
||||
api.POST("/ai/release", handler.AiRelease(hub))
|
||||
api.POST("/batch/parse", handler.BatchParse)
|
||||
|
||||
// 流水线:查看 viewer / 编辑配置 owner / 触发构建 operator;新建与删除仅管理员
|
||||
api.GET("/pipelines", handler.ListPipelines)
|
||||
api.POST("/pipelines", handler.CreatePipeline)
|
||||
api.GET("/pipelines/:id", handler.GetPipelineDetail)
|
||||
api.PUT("/pipelines/:id", handler.UpdatePipeline)
|
||||
api.DELETE("/pipelines/:id", handler.DeletePipeline)
|
||||
api.PUT("/pipelines/:id/nodes", handler.SaveNodes)
|
||||
api.PUT("/pipelines/:id/edges", handler.SaveEdges)
|
||||
api.POST("/pipelines/:id/runs", handler.CreateRun(hub))
|
||||
api.POST("/pipelines", handler.AdminOnly(), handler.CreatePipeline)
|
||||
api.GET("/pipelines/:id", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelViewer)), handler.GetPipelineDetail)
|
||||
api.PUT("/pipelines/:id", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.UpdatePipeline)
|
||||
api.DELETE("/pipelines/:id", handler.AdminOnly(), handler.DeletePipeline)
|
||||
api.PUT("/pipelines/:id/nodes", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.SaveNodes)
|
||||
api.PUT("/pipelines/:id/edges", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.SaveEdges)
|
||||
api.POST("/pipelines/:id/runs", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOperator)), handler.CreateRun(hub))
|
||||
api.POST("/hooks/pipeline/:id", handler.InboundPipelineHook(hub))
|
||||
|
||||
// 单流水线成员:查看列表 viewer,增删 owner
|
||||
api.GET("/pipelines/:id/members", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelViewer)), handler.ListPipelineMembers)
|
||||
api.POST("/pipelines/:id/members", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.AddPipelineMember)
|
||||
api.DELETE("/pipelines/:id/members/:mid", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.DeletePipelineMember)
|
||||
|
||||
api.GET("/runs", handler.ListRuns)
|
||||
api.GET("/runs/:runId", handler.GetRun)
|
||||
api.GET("/runs/:runId/logs/stream", handler.StreamLogs(hub))
|
||||
api.POST("/runs/:runId/retry", handler.RetryRun(hub))
|
||||
api.GET("/runs/:runId", handler.RequireRunAccess(handler.LevelRank(handler.LevelViewer)), handler.GetRun)
|
||||
api.GET("/runs/:runId/logs/stream", handler.RequireRunAccess(handler.LevelRank(handler.LevelViewer)), handler.StreamLogs(hub))
|
||||
api.POST("/runs/:runId/retry", handler.RequireRunAccess(handler.LevelRank(handler.LevelOperator)), handler.RetryRun(hub))
|
||||
|
||||
api.GET("/gitops/apps", handler.ListGitOpsApps)
|
||||
api.GET("/gitops/argo/apps", handler.ArgoList)
|
||||
api.POST("/gitops/argo/sync", handler.ArgoSync)
|
||||
api.GET("/gitops/argo/:appName/tree", handler.ArgoResourceTree)
|
||||
api.GET("/gitops/argo/:appName/diff", handler.ArgoDiff)
|
||||
// GitOps 部署(全局基础设施操作,未按流水线隔离)→ 管理员
|
||||
gitops := api.Group("/gitops", handler.AdminOnly())
|
||||
{
|
||||
gitops.GET("/apps", handler.ListGitOpsApps)
|
||||
gitops.GET("/argo/apps", handler.ArgoList)
|
||||
gitops.POST("/argo/sync", handler.ArgoSync)
|
||||
gitops.GET("/argo/:appName/tree", handler.ArgoResourceTree)
|
||||
gitops.GET("/argo/:appName/diff", handler.ArgoDiff)
|
||||
}
|
||||
|
||||
api.GET("/plugins", handler.ListPlugins)
|
||||
api.GET("/plugins/available", handler.AvailablePlugins)
|
||||
api.POST("/plugins/install", handler.InstallPlugin)
|
||||
api.POST("/plugins/:id/toggle", handler.TogglePlugin)
|
||||
api.DELETE("/plugins/:id", handler.UninstallPlugin)
|
||||
// 插件市场 → 管理员
|
||||
plugins := api.Group("/plugins", handler.AdminOnly())
|
||||
{
|
||||
plugins.GET("", handler.ListPlugins)
|
||||
plugins.GET("/available", handler.AvailablePlugins)
|
||||
plugins.POST("/install", handler.InstallPlugin)
|
||||
plugins.POST("/:id/toggle", handler.TogglePlugin)
|
||||
plugins.DELETE("/:id", handler.UninstallPlugin)
|
||||
}
|
||||
|
||||
api.GET("/hosts", handler.ListHosts)
|
||||
api.POST("/hosts", handler.CreateHost)
|
||||
api.PUT("/hosts/:id", handler.UpdateHost)
|
||||
api.DELETE("/hosts/:id", handler.DeleteHost)
|
||||
api.POST("/hosts/:id/test", handler.TestHost)
|
||||
|
||||
api.GET("/clusters", handler.ListClusters)
|
||||
api.POST("/clusters", handler.CreateCluster)
|
||||
api.PUT("/clusters/:id", handler.UpdateCluster)
|
||||
api.DELETE("/clusters/:id", handler.DeleteCluster)
|
||||
api.POST("/clusters/:id/test", handler.TestCluster)
|
||||
|
||||
api.GET("/credentials", handler.ListCredentials)
|
||||
api.POST("/credentials", handler.CreateCredential)
|
||||
api.PUT("/credentials/:id", handler.UpdateCredential)
|
||||
api.DELETE("/credentials/:id", handler.DeleteCredential)
|
||||
|
||||
api.GET("/build-nodes", handler.ListBuildNodes)
|
||||
api.POST("/build-nodes", handler.CreateBuildNode)
|
||||
api.PUT("/build-nodes/:id", handler.UpdateBuildNode)
|
||||
api.DELETE("/build-nodes/:id", handler.DeleteBuildNode)
|
||||
api.POST("/build-nodes/:id/test", handler.TestBuildNode)
|
||||
api.POST("/build-nodes/:id/disk-test", handler.TestBuildNodeDisk)
|
||||
|
||||
api.GET("/tools", handler.ListTools)
|
||||
api.PUT("/tools/:id", handler.UpdateTool)
|
||||
api.POST("/tools/:id/check", handler.CheckTool)
|
||||
api.POST("/tools/:id/install", handler.InstallTool)
|
||||
|
||||
api.GET("/webhooks", handler.ListWebhooks)
|
||||
api.POST("/webhooks", handler.CreateWebhook)
|
||||
api.PUT("/webhooks/:id", handler.UpdateWebhook)
|
||||
api.DELETE("/webhooks/:id", handler.DeleteWebhook)
|
||||
api.POST("/webhooks/:id/test", handler.TestWebhook)
|
||||
|
||||
api.GET("/jenkins-servers", handler.ListJenkinsServers)
|
||||
api.POST("/jenkins-servers", handler.CreateJenkinsServer)
|
||||
api.PUT("/jenkins-servers/:id", handler.UpdateJenkinsServer)
|
||||
api.DELETE("/jenkins-servers/:id", handler.DeleteJenkinsServer)
|
||||
api.POST("/jenkins-servers/:id/test", handler.TestJenkinsServer)
|
||||
api.GET("/jenkins-servers/:id/jobs", handler.ListJenkinsJobs)
|
||||
api.POST("/jenkins-servers/:id/import", handler.ImportJenkinsJobs)
|
||||
// 资源(主机/集群/凭证/构建节点/工具/Webhook)与 Jenkins 集成 → 管理员
|
||||
hosts := api.Group("/hosts", handler.AdminOnly())
|
||||
{
|
||||
hosts.GET("", handler.ListHosts)
|
||||
hosts.POST("", handler.CreateHost)
|
||||
hosts.PUT("/:id", handler.UpdateHost)
|
||||
hosts.DELETE("/:id", handler.DeleteHost)
|
||||
hosts.POST("/:id/test", handler.TestHost)
|
||||
}
|
||||
clusters := api.Group("/clusters", handler.AdminOnly())
|
||||
{
|
||||
clusters.GET("", handler.ListClusters)
|
||||
clusters.POST("", handler.CreateCluster)
|
||||
clusters.PUT("/:id", handler.UpdateCluster)
|
||||
clusters.DELETE("/:id", handler.DeleteCluster)
|
||||
clusters.POST("/:id/test", handler.TestCluster)
|
||||
}
|
||||
credentials := api.Group("/credentials", handler.AdminOnly())
|
||||
{
|
||||
credentials.GET("", handler.ListCredentials)
|
||||
credentials.POST("", handler.CreateCredential)
|
||||
credentials.PUT("/:id", handler.UpdateCredential)
|
||||
credentials.DELETE("/:id", handler.DeleteCredential)
|
||||
}
|
||||
buildNodes := api.Group("/build-nodes", handler.AdminOnly())
|
||||
{
|
||||
buildNodes.GET("", handler.ListBuildNodes)
|
||||
buildNodes.POST("", handler.CreateBuildNode)
|
||||
buildNodes.PUT("/:id", handler.UpdateBuildNode)
|
||||
buildNodes.DELETE("/:id", handler.DeleteBuildNode)
|
||||
buildNodes.POST("/:id/test", handler.TestBuildNode)
|
||||
buildNodes.POST("/:id/disk-test", handler.TestBuildNodeDisk)
|
||||
}
|
||||
tools := api.Group("/tools", handler.AdminOnly())
|
||||
{
|
||||
tools.GET("", handler.ListTools)
|
||||
tools.PUT("/:id", handler.UpdateTool)
|
||||
tools.POST("/:id/check", handler.CheckTool)
|
||||
tools.POST("/:id/install", handler.InstallTool)
|
||||
}
|
||||
webhooks := api.Group("/webhooks", handler.AdminOnly())
|
||||
{
|
||||
webhooks.GET("", handler.ListWebhooks)
|
||||
webhooks.POST("", handler.CreateWebhook)
|
||||
webhooks.PUT("/:id", handler.UpdateWebhook)
|
||||
webhooks.DELETE("/:id", handler.DeleteWebhook)
|
||||
webhooks.POST("/:id/test", handler.TestWebhook)
|
||||
}
|
||||
jenkins := api.Group("/jenkins-servers", handler.AdminOnly())
|
||||
{
|
||||
jenkins.GET("", handler.ListJenkinsServers)
|
||||
jenkins.POST("", handler.CreateJenkinsServer)
|
||||
jenkins.PUT("/:id", handler.UpdateJenkinsServer)
|
||||
jenkins.DELETE("/:id", handler.DeleteJenkinsServer)
|
||||
jenkins.POST("/:id/test", handler.TestJenkinsServer)
|
||||
jenkins.GET("/:id/jobs", handler.ListJenkinsJobs)
|
||||
jenkins.POST("/:id/import", handler.ImportJenkinsJobs)
|
||||
}
|
||||
}
|
||||
|
||||
distFS, _ := fs.Sub(staticFS, "static")
|
||||
|
||||
+39
-3
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import TopBar from '@/components/TopBar';
|
||||
import PipelineFlowEditor from '@/components/flow/PipelineFlowEditor';
|
||||
@@ -24,11 +24,19 @@ import AiAssistant from '@/components/ai/AiAssistant';
|
||||
import Login from '@/pages/Login';
|
||||
import UserManage from '@/pages/UserManage';
|
||||
import Docs from '@/pages/Docs';
|
||||
import PipelineRoles from '@/pages/PipelineRoles';
|
||||
import { apiClient, auth } from '@/lib/api';
|
||||
import { useSession } from '@/providers/session';
|
||||
|
||||
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'plugins' | 'users' | 'docs' | 'settings';
|
||||
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'plugins' | 'users' | 'docs' | 'settings' | 'pipeline-roles';
|
||||
export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
||||
|
||||
// 仅管理员可见的模块(资源/集成/系统管理类)
|
||||
const ADMIN_ONLY_MODULES = new Set<ModuleType>([
|
||||
'gitops', 'environments', 'credentials', 'build-nodes', 'tools', 'jenkins-import',
|
||||
'plugins', 'users', 'settings', 'pipeline-roles',
|
||||
]);
|
||||
|
||||
// 路由 ↔ 模块映射(用户可直接访问 /release、/pipelines/1 等)
|
||||
const ROUTE_TO_MODULE: Record<string, ModuleType> = {
|
||||
'/': 'pipelines',
|
||||
@@ -45,6 +53,7 @@ const ROUTE_TO_MODULE: Record<string, ModuleType> = {
|
||||
'/users': 'users',
|
||||
'/docs': 'docs',
|
||||
'/settings': 'settings',
|
||||
'/pipeline-roles': 'pipeline-roles',
|
||||
};
|
||||
|
||||
const MODULE_TO_ROUTE: Record<ModuleType, string> = {
|
||||
@@ -61,12 +70,15 @@ const MODULE_TO_ROUTE: Record<ModuleType, string> = {
|
||||
users: '/users',
|
||||
docs: '/docs',
|
||||
settings: '/settings',
|
||||
'pipeline-roles': '/pipeline-roles',
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [loggedIn, setLoggedIn] = useState(!!auth.getToken());
|
||||
const { isAdmin, loading: sessionLoading } = useSession();
|
||||
|
||||
// 未登录访问任何页面 → 跳登录页
|
||||
useEffect(() => {
|
||||
@@ -85,6 +97,13 @@ export default function App() {
|
||||
const [activeModule, setActiveModule] = useState<ModuleType>('pipelines');
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
// 普通用户访问管理员模块 → 回流水线列表(菜单已隐藏,此处防直接输 URL)
|
||||
useEffect(() => {
|
||||
if (loggedIn && !sessionLoading && !isAdmin && ADMIN_ONLY_MODULES.has(activeModule)) {
|
||||
navigate('/pipelines', { replace: true });
|
||||
}
|
||||
}, [loggedIn, sessionLoading, isAdmin, activeModule, navigate]);
|
||||
|
||||
// Pipeline list/editor view toggle
|
||||
const [pipelineView, setPipelineView] = useState<'list' | 'editor'>('list');
|
||||
const [currentPipelineId, setCurrentPipelineId] = useState<number>(1);
|
||||
@@ -137,6 +156,8 @@ export default function App() {
|
||||
const currentPipeline = pipelines.find((p: any) => p.id === currentPipelineId);
|
||||
const latestStatus = currentRuns[0]?.status as string | undefined;
|
||||
const isPipelineRunning = latestStatus === 'running' || latestStatus === 'pending';
|
||||
// 当前用户对该流水线的权限等级:1 查看 / 2 执行 / 3 管理 / 4 管理员(列表未加载前按 4 乐观显示,后端强校验)
|
||||
const editorAccessLevel = currentPipeline?.accessLevel ?? 4;
|
||||
|
||||
const handleSelectPipeline = useCallback((id: string) => {
|
||||
setCurrentPipelineId(Number(id) || 1);
|
||||
@@ -183,6 +204,7 @@ export default function App() {
|
||||
isRunning={isPipelineRunning}
|
||||
executionStatus={(latestStatus as 'running' | 'success' | 'failed' | 'cancelled') || 'idle'}
|
||||
onBack={() => { setPipelineView('list'); navigate('/pipelines'); }}
|
||||
accessLevel={editorAccessLevel}
|
||||
/>
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{activeTab === '流程配置' && (
|
||||
@@ -233,6 +255,8 @@ export default function App() {
|
||||
return <PluginManager />;
|
||||
case 'users':
|
||||
return <UserManage />;
|
||||
case 'pipeline-roles':
|
||||
return <PipelineRoles />;
|
||||
case 'docs':
|
||||
return <Docs />;
|
||||
case 'settings':
|
||||
@@ -243,7 +267,19 @@ export default function App() {
|
||||
};
|
||||
|
||||
if (!loggedIn || location.pathname === '/login') {
|
||||
return <Login onSuccess={() => { setLoggedIn(true); navigate('/pipelines', { replace: true }); }} />;
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-right" richColors />
|
||||
<Login
|
||||
onSuccess={() => {
|
||||
setLoggedIn(true);
|
||||
// 刷新会话信息(切换账号后 ['me'] 缓存需要失效)
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
navigate('/pipelines', { replace: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,8 @@ export default function BasicInfoPanel({ pipelineId }: Props) {
|
||||
queryFn: () => apiClient.getPipeline(pipelineId),
|
||||
});
|
||||
const p = detail?.pipeline;
|
||||
// 编辑权限:「管理」等级及以上才能保存
|
||||
const canEdit = (detail?.accessLevel ?? 4) >= 3;
|
||||
|
||||
const [form, setForm] = useState({ name: '', description: '', sourceRepo: '', sourceBranch: '', deployType: 'host', env: 'staging', notifyWebhookURL: '' });
|
||||
|
||||
@@ -56,6 +58,7 @@ export default function BasicInfoPanel({ pipelineId }: Props) {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{p && <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>更新于 {new Date(p.updatedAt).toLocaleString('zh-CN', { hour12: false })}</span>}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => saveMutation.mutate(form)}
|
||||
disabled={saveMutation.isPending || !form.name}
|
||||
@@ -64,6 +67,7 @@ export default function BasicInfoPanel({ pipelineId }: Props) {
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Trash2, UserPlus } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
// 三级权限:大白话描述,普通操作员无需理解术语
|
||||
export const LEVEL_OPTIONS = [
|
||||
{ value: 'viewer', label: '查看', desc: '只能看流水线与发布成功/失败日志' },
|
||||
{ value: 'operator', label: '发布', desc: '查看 + 触发构建、批量发布、失败重试' },
|
||||
{ value: 'owner', label: '管理', desc: '发布 + 编辑流水线配置、管理成员' },
|
||||
];
|
||||
|
||||
export function levelLabel(level: string): string {
|
||||
return LEVEL_OPTIONS.find((l) => l.value === level)?.label ?? level;
|
||||
}
|
||||
|
||||
export const levelBadgeStyle: Record<string, { bg: string; color: string }> = {
|
||||
viewer: { bg: '#f6f8fa', color: '#6e7781' },
|
||||
operator: { bg: '#ddf4ff', color: '#0969da' },
|
||||
owner: { bg: '#dafbe1', color: '#1a7f37' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
pipelineId: number;
|
||||
pipelineName?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 单流水线成员弹窗:直接添加成员(选用户 + 点选等级),命中的权限组只读展示
|
||||
export default function PipelineMembersDialog({ pipelineId, pipelineName, open, onClose }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [username, setUsername] = useState('');
|
||||
const [level, setLevel] = useState('operator');
|
||||
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['pipeline-members', pipelineId],
|
||||
queryFn: () => apiClient.listPipelineMembers(pipelineId),
|
||||
enabled: open,
|
||||
});
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: apiClient.listUsers,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['pipeline-members', pipelineId] });
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => apiClient.addPipelineMember(pipelineId, { username, level }),
|
||||
onSuccess: () => {
|
||||
toast.success(`已授权 ${username}`);
|
||||
setUsername('');
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (mid: any) => apiClient.deletePipelineMember(pipelineId, mid),
|
||||
onSuccess: () => {
|
||||
toast.success('已移除授权');
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const members: any[] = membersQuery.data?.members ?? [];
|
||||
const matchedRoles: any[] = membersQuery.data?.matchedRoles ?? [];
|
||||
const userOptions: any[] = users ?? [];
|
||||
|
||||
const submit = () => {
|
||||
if (!username) {
|
||||
toast.error('请选择用户');
|
||||
return;
|
||||
}
|
||||
addMutation.mutate();
|
||||
};
|
||||
|
||||
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' };
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[15px]">成员授权 — {pipelineName || `流水线 #${pipelineId}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 添加成员 */}
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户</Label>
|
||||
<Select value={username} onValueChange={setUsername}>
|
||||
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
|
||||
<SelectValue placeholder="选择用户" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userOptions.map((u) => (
|
||||
<SelectItem key={u.id} value={u.username} className="text-[12px]">
|
||||
{u.username}{u.nickname ? `(${u.nickname})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>权限等级</Label>
|
||||
<div className="flex gap-1">
|
||||
{LEVEL_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setLevel(opt.value)}
|
||||
className="flex-1 h-8 text-[12px] rounded-md border transition-colors"
|
||||
style={
|
||||
level === opt.value
|
||||
? { background: 'var(--accent-blue-light)', borderColor: 'var(--accent-blue)', color: 'var(--accent-blue)', fontWeight: 600 }
|
||||
: { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }
|
||||
}
|
||||
title={opt.desc}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{LEVEL_OPTIONS.find((l) => l.value === level)?.desc}
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white disabled:opacity-60"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
disabled={addMutation.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
{addMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <UserPlus className="w-3.5 h-3.5" />}
|
||||
添加授权
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 已有直接授权 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[12px] font-medium" style={{ color: 'var(--text-secondary)' }}>直接授权({members.length})</div>
|
||||
{membersQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="py-2 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>暂无直接授权成员</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
{members.map((m) => {
|
||||
const badge = levelBadgeStyle[m.level] ?? levelBadgeStyle.viewer;
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between px-3 py-2 border-b last:border-b-0"
|
||||
style={{ borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-mono" style={{ color: 'var(--text-primary)' }}>{m.username}</span>
|
||||
<span className="text-[11px] px-2 py-[2px] rounded font-medium" style={{ background: badge.bg, color: badge.color }}>
|
||||
{levelLabel(m.level)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="移除授权"
|
||||
disabled={removeMutation.isPending}
|
||||
onClick={() => removeMutation.mutate(m.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 命中的权限组(只读) */}
|
||||
{matchedRoles.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[12px] font-medium" style={{ color: 'var(--text-secondary)' }}>命中的权限组(只读,在「流水线权限」页管理)</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{matchedRoles.map((r, i) => {
|
||||
const badge = levelBadgeStyle[r.level] ?? levelBadgeStyle.viewer;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[11px] px-2 py-1 rounded border"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
title={r.mode === 'pattern' ? `名称匹配:${r.pattern}` : '手动选择的流水线'}
|
||||
>
|
||||
{r.name} · {r.mode === 'pattern' ? r.pattern : '多选'} ·{' '}
|
||||
<span style={{ color: badge.color }}>{levelLabel(r.level)}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,13 @@ export default function ReleasePanel({ pipelineId }: Props) {
|
||||
enabled: !!expandedRunId,
|
||||
});
|
||||
|
||||
// 当前用户对该流水线的权限(详情接口附带):重试需要「执行」等级
|
||||
const { data: pipelineDetail } = useQuery({
|
||||
queryKey: ['pipeline', pipelineId],
|
||||
queryFn: () => apiClient.getPipeline(pipelineId),
|
||||
});
|
||||
const canRun = (pipelineDetail?.accessLevel ?? 4) >= 2;
|
||||
|
||||
const expandedRun = runs.find((r) => r.runId === expandedRunId);
|
||||
const isRunning = expandedRun?.status === 'running' || expandedRun?.status === 'pending';
|
||||
const { logs: liveLogs, connected } = usePipelineLogs(expandedRunId, !!expandedRunId && isRunning);
|
||||
@@ -104,7 +111,7 @@ export default function ReleasePanel({ pipelineId }: Props) {
|
||||
<span className="text-[11px] w-36 text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(r.startedAt).toLocaleString('zh-CN', { hour12: false })}
|
||||
</span>
|
||||
{(r.status === 'failed' || r.status === 'cancelled') && (
|
||||
{(r.status === 'failed' || r.status === 'cancelled') && canRun && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); rerunMutation.mutate(r.runId); }}
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
|
||||
@@ -18,10 +18,11 @@ import {
|
||||
Sun,
|
||||
Moon,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/providers/theme';
|
||||
|
||||
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'plugins' | 'users' | 'docs' | 'settings';
|
||||
import { useSession } from '@/providers/session';
|
||||
import type { ModuleType } from '@/App';
|
||||
|
||||
interface SidebarProps {
|
||||
activeModule: ModuleType;
|
||||
@@ -31,18 +32,20 @@ interface SidebarProps {
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: typeof GitBranch }[] }[] = [
|
||||
// adminOnly:普通用户隐藏(资源/集成含敏感配置,系统管理类同理)
|
||||
const menuGroups: { label: string; adminOnly?: boolean; items: { id: ModuleType; label: string; icon: typeof GitBranch; adminOnly?: boolean }[] }[] = [
|
||||
{
|
||||
label: '发布',
|
||||
items: [
|
||||
{ id: 'pipelines', label: '流水线', icon: GitBranch },
|
||||
{ id: 'gitops', label: 'GitOps 部署', icon: CloudSync },
|
||||
{ id: 'batch-release', label: '批量发布', icon: Zap },
|
||||
{ id: 'release-records', label: '发布记录', icon: History },
|
||||
{ id: 'gitops', label: 'GitOps 部署', icon: CloudSync, adminOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '资源',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ id: 'environments', label: '环境管理', icon: Server },
|
||||
{ id: 'credentials', label: '凭证管理', icon: Key },
|
||||
@@ -51,6 +54,7 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon:
|
||||
},
|
||||
{
|
||||
label: '集成',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ id: 'jenkins-import', label: 'Jenkins导入', icon: Download },
|
||||
{ id: 'tools', label: '工具配置', icon: Wrench },
|
||||
@@ -60,15 +64,24 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon:
|
||||
label: '系统',
|
||||
items: [
|
||||
{ id: 'docs', label: '文档中心', icon: BookOpen },
|
||||
{ id: 'plugins', label: '插件市场', icon: Puzzle },
|
||||
{ id: 'users', label: '用户管理', icon: Users },
|
||||
{ id: 'settings', label: '系统设置', icon: Settings },
|
||||
{ id: 'pipeline-roles', label: '流水线权限', icon: ShieldCheck, adminOnly: true },
|
||||
{ id: 'plugins', label: '插件市场', icon: Puzzle, adminOnly: true },
|
||||
{ id: 'users', label: '用户管理', icon: Users, adminOnly: true },
|
||||
{ id: 'settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse, onLogout }: SidebarProps) {
|
||||
const { toggle: themeToggle, isDark } = useTheme();
|
||||
const { isAdmin } = useSession();
|
||||
|
||||
const visibleGroups = menuGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => isAdmin || !item.adminOnly),
|
||||
}))
|
||||
.filter((group) => isAdmin || !group.adminOnly);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -99,7 +112,7 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog
|
||||
|
||||
{/* Menu groups */}
|
||||
<div className="flex-1 py-2 overflow-y-auto custom-scrollbar">
|
||||
{menuGroups.map((group) => (
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.label} className="mb-2">
|
||||
{!collapsed && (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronLeft, Save, Play, ChevronDown, Loader2 } from 'lucide-react';
|
||||
import { ChevronLeft, Save, Play, ChevronDown, Loader2, UsersRound } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import PipelineMembersDialog from './PipelineMembersDialog';
|
||||
|
||||
type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
||||
|
||||
@@ -21,6 +22,7 @@ interface TopBarProps {
|
||||
isRunning?: boolean;
|
||||
executionStatus: 'idle' | 'running' | 'success' | 'failed' | 'cancelled';
|
||||
onBack?: () => void;
|
||||
accessLevel?: number; // 1 查看 / 2 执行 / 3 管理 / 4 管理员
|
||||
}
|
||||
|
||||
const tabs: EditorTab[] = ['基本信息', '流程配置', '触发设置', '变量和缓存', '发布'];
|
||||
@@ -38,8 +40,10 @@ export default function TopBar({
|
||||
isRunning = false,
|
||||
executionStatus,
|
||||
onBack,
|
||||
accessLevel = 4,
|
||||
}: TopBarProps) {
|
||||
const [pipelineDropdownOpen, setPipelineDropdownOpen] = useState(false);
|
||||
const [membersOpen, setMembersOpen] = useState(false);
|
||||
|
||||
const statusConfig: Record<string, { color: string; bg: string; label: string } | null> = {
|
||||
idle: null,
|
||||
@@ -146,31 +150,56 @@ export default function TopBar({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: Action Buttons */}
|
||||
{/* Right: Action Buttons(按权限等级显隐:保存≥管理、运行≥执行、成员≥管理) */}
|
||||
<div className="flex items-center gap-2 min-w-[280px] justify-end">
|
||||
<button
|
||||
onClick={onOnlySave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border-color)',
|
||||
}}
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
仅保存
|
||||
</button>
|
||||
<button
|
||||
onClick={onSaveAndRun}
|
||||
disabled={isRunning}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors disabled:opacity-60"
|
||||
style={{ background: isRunning ? '#6e7781' : 'var(--accent-blue)' }}
|
||||
>
|
||||
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{isRunning ? '运行中…' : '保存并运行'}
|
||||
</button>
|
||||
{accessLevel >= 3 && (
|
||||
<button
|
||||
onClick={() => setMembersOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border-color)',
|
||||
}}
|
||||
title="管理该流水线的成员授权"
|
||||
>
|
||||
<UsersRound className="w-4 h-4" />
|
||||
成员
|
||||
</button>
|
||||
)}
|
||||
{accessLevel >= 3 && (
|
||||
<button
|
||||
onClick={onOnlySave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border-color)',
|
||||
}}
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
仅保存
|
||||
</button>
|
||||
)}
|
||||
{accessLevel >= 2 && (
|
||||
<button
|
||||
onClick={onSaveAndRun}
|
||||
disabled={isRunning}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors disabled:opacity-60"
|
||||
style={{ background: isRunning ? '#6e7781' : 'var(--accent-blue)' }}
|
||||
>
|
||||
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{isRunning ? '运行中…' : '保存并运行'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<PipelineMembersDialog
|
||||
pipelineId={currentPipelineId}
|
||||
pipelineName={pipelineName}
|
||||
open={membersOpen}
|
||||
onClose={() => setMembersOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export default function TriggerPanel({ pipelineId }: Props) {
|
||||
queryFn: () => apiClient.getPipeline(pipelineId),
|
||||
});
|
||||
const p = detail?.pipeline;
|
||||
// 编辑权限:「管理」等级及以上才能保存
|
||||
const canEdit = (detail?.accessLevel ?? 4) >= 3;
|
||||
|
||||
const [form, setForm] = useState({ triggerMode: 'manual', cronExpr: '', webhookToken: '', watchBranches: '' });
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -68,6 +70,7 @@ export default function TriggerPanel({ pipelineId }: Props) {
|
||||
<div className="rounded-lg border p-6 shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>触发方式</h3>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => saveMutation.mutate(form)}
|
||||
disabled={saveMutation.isPending}
|
||||
@@ -76,6 +79,7 @@ export default function TriggerPanel({ pipelineId }: Props) {
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -28,6 +28,8 @@ export default function VariablesPanel({ pipelineId }: Props) {
|
||||
queryFn: () => apiClient.getPipeline(pipelineId),
|
||||
});
|
||||
const p = detail?.pipeline;
|
||||
// 编辑权限:「管理」等级及以上才能保存
|
||||
const canEdit = (detail?.accessLevel ?? 4) >= 3;
|
||||
|
||||
const [vars, setVars] = useState<VarItem[]>([]);
|
||||
const [caches, setCaches] = useState<CacheItem[]>([]);
|
||||
@@ -105,9 +107,13 @@ export default function VariablesPanel({ pipelineId }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
{canEdit ? (
|
||||
<button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending} className="flex items-center gap-1 px-4 py-1.5 text-[12px] rounded-md text-white disabled:opacity-50" style={{ background: 'var(--accent-blue)' }}>
|
||||
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存全部
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>只读(需要「管理」权限才能编辑)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import TektonNode, { type TektonNodeData } from './TektonNode';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { apiClient, auth } from '@/lib/api';
|
||||
|
||||
interface FlowEditorProps {
|
||||
pipelineId: number;
|
||||
@@ -177,7 +177,9 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit
|
||||
setAllNodeStatus('pending');
|
||||
toast.success(`运行已触发 ${run.runId}`, { description: `${nodes.length} 个节点开始执行` });
|
||||
|
||||
const es = new EventSource(`/api/runs/${run.runId}/logs/stream`);
|
||||
// SSE 无法携带 Authorization 头,token 通过查询参数传递
|
||||
const token = encodeURIComponent(auth.getToken() ?? '');
|
||||
const es = new EventSource(`/api/runs/${run.runId}/logs/stream?token=${token}`);
|
||||
es.addEventListener('log', (evt) => {
|
||||
try {
|
||||
const entry = JSON.parse((evt as MessageEvent).data);
|
||||
@@ -342,9 +344,12 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit
|
||||
{catalogOpen ? <ChevronLeft className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}Task 库
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(pipelineData?.accessLevel ?? 4) >= 3 && (
|
||||
<button onClick={handleSave} className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<Save className="w-3 h-3" />保存
|
||||
</button>
|
||||
)}
|
||||
{(pipelineData?.accessLevel ?? 4) >= 2 && (
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isRunning}
|
||||
@@ -355,6 +360,7 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit
|
||||
? <><CircleDot className="w-3 h-3 animate-spin" />执行中…</>
|
||||
: <><Play className="w-3 h-3" />执行</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { auth } from '@/lib/api';
|
||||
|
||||
export interface LogMessage {
|
||||
id?: number;
|
||||
@@ -26,7 +27,9 @@ export function usePipelineLogs(runId: string | null, enabled: boolean) {
|
||||
return;
|
||||
}
|
||||
|
||||
const es = new EventSource(`/api/runs/${runId}/logs/stream`);
|
||||
// SSE 无法携带 Authorization 头,token 通过查询参数传递(后端校验签名与 viewer 权限)
|
||||
const token = encodeURIComponent(auth.getToken() ?? '');
|
||||
const es = new EventSource(`/api/runs/${runId}/logs/stream?token=${token}`);
|
||||
|
||||
es.addEventListener('connected', () => setConnected(true));
|
||||
es.addEventListener('log', (evt) => {
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface PipelineDetail {
|
||||
pipeline: any
|
||||
nodes: PipelineNodeDTO[]
|
||||
edges: PipelineEdgeDTO[]
|
||||
accessLevel?: number // 1 查看 / 2 执行 / 3 管理 / 4 管理员
|
||||
}
|
||||
|
||||
export interface PipelineRunDTO {
|
||||
@@ -178,6 +179,7 @@ export const apiClient = {
|
||||
// 用户与会话
|
||||
login: (username: string, password: string) =>
|
||||
api<any>('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
me: () => api<{ username: string; role: string; isAdmin: boolean }>('/api/me'),
|
||||
listUsers: () => api<any[]>('/api/users'),
|
||||
createUser: (b: any) => api<any>('/api/users', { method: 'POST', body: JSON.stringify(b) }),
|
||||
updateUser: (id: any, b: any) => api(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||
@@ -187,4 +189,14 @@ export const apiClient = {
|
||||
ldapImport: (users: any[]) => api<any>('/api/users/ldap-import', { method: 'POST', body: JSON.stringify({ users }) }),
|
||||
testLDAP: (body: any) => api<any>('/api/settings/ldap/test', { method: 'POST', body: JSON.stringify(body) }),
|
||||
getDocs: () => api<any>('/api/docs'),
|
||||
// 流水线权限组(管理员)与单流水线成员
|
||||
listPipelineRoles: () => api<any[]>('/api/pipeline-roles'),
|
||||
createPipelineRole: (b: any) => api<any>('/api/pipeline-roles', { method: 'POST', body: JSON.stringify(b) }),
|
||||
updatePipelineRole: (id: any, b: any) => api<any>(`/api/pipeline-roles/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||
deletePipelineRole: (id: any) => api(`/api/pipeline-roles/${id}`, { method: 'DELETE' }),
|
||||
listPipelineMembers: (pipelineId: number | string) => api<any>(`/api/pipelines/${pipelineId}/members`),
|
||||
addPipelineMember: (pipelineId: number | string, body: { username: string; level: string }) =>
|
||||
api<any>(`/api/pipelines/${pipelineId}/members`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
deletePipelineMember: (pipelineId: number | string, memberId: any) =>
|
||||
api(`/api/pipelines/${pipelineId}/members/${memberId}`, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -3,9 +3,26 @@ import { Rocket, Loader2, KeyRound, User } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { apiClient, auth } from '@/lib/api';
|
||||
|
||||
const REMEMBER_KEY = 'flowstack-remember';
|
||||
|
||||
// 记住密码:明文存 localStorage 仅适合内网工具场景;不勾选则彻底不落盘
|
||||
function readRemember(): { username: string; password: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(REMEMBER_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.username && parsed?.password) return parsed;
|
||||
} catch {
|
||||
// 脏数据忽略
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const remembered = readRemember();
|
||||
const [username, setUsername] = useState(remembered?.username ?? '');
|
||||
const [password, setPassword] = useState(remembered?.password ?? '');
|
||||
const [remember, setRemember] = useState(!!remembered);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e?: React.FormEvent) => {
|
||||
@@ -18,6 +35,11 @@ export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
try {
|
||||
const res = await apiClient.login(username.trim(), password);
|
||||
auth.setToken(res.token);
|
||||
if (remember) {
|
||||
localStorage.setItem(REMEMBER_KEY, JSON.stringify({ username: username.trim(), password }));
|
||||
} else {
|
||||
localStorage.removeItem(REMEMBER_KEY);
|
||||
}
|
||||
toast.success(`欢迎,${res.nickname || res.username}`);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
@@ -69,6 +91,15 @@ export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none" title="在本机记住账号与密码,公共电脑请勿勾选">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="w-3.5 h-3.5 accent-[var(--accent-blue)]"
|
||||
/>
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>记住密码</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { useSession } from '@/providers/session';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
@@ -120,6 +121,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState<CreatePipelineForm>(emptyForm);
|
||||
const { isAdmin } = useSession();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -163,6 +165,8 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
description: p.description || '',
|
||||
branch: p.sourceBranch || '-',
|
||||
type: p.type,
|
||||
// 后端附带的当前用户权限等级:1 查看 / 2 执行 / 3 管理 / 4 管理员
|
||||
accessLevel: (p as any).accessLevel ?? 4,
|
||||
deploy: deployTypeConfig[p.deployType] || fallbackDeploy,
|
||||
// 筛选用映射状态:lastRun 为空记为 pending(未执行)
|
||||
mappedStatus: p.lastRun?.status ?? 'pending',
|
||||
@@ -289,15 +293,17 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
<List className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
{/* New pipeline button */}
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
新建流水线
|
||||
</button>
|
||||
{/* New pipeline button(新建/删除仅管理员) */}
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
新建流水线
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -314,7 +320,11 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件或新建流水线</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{items.length === 0 && !isAdmin
|
||||
? '暂无被授权的流水线,请联系管理员在「流水线权限」中授权'
|
||||
: '尝试调整搜索条件或新建流水线'}
|
||||
</p>
|
||||
</div>
|
||||
) : viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
@@ -397,19 +407,21 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
runMutation.mutate(pipeline.raw.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="执行"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
</button>
|
||||
{pipeline.accessLevel >= 2 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
runMutation.mutate(pipeline.raw.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="执行"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="配置"
|
||||
title={pipeline.accessLevel >= 3 ? '配置' : '查看'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectPipeline(pipeline.id);
|
||||
@@ -417,16 +429,18 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(pipeline);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(pipeline);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -506,19 +520,21 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerText}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
runMutation.mutate(pipeline.raw.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="执行"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
</button>
|
||||
{pipeline.accessLevel >= 2 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
runMutation.mutate(pipeline.raw.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="执行"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="配置"
|
||||
title={pipeline.accessLevel >= 3 ? '配置' : '查看'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectPipeline(pipeline.id);
|
||||
@@ -526,16 +542,18 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(pipeline);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(pipeline);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Plus, Search, Trash2, Edit2, Loader2, ShieldCheck, ListChecks, Wand2, CheckSquare, Square, X,
|
||||
} from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { LEVEL_OPTIONS, levelLabel, levelBadgeStyle } from '@/components/PipelineMembersDialog';
|
||||
|
||||
// 与后端 globToRegexp 语义一致:* 任意字符、? 单字符,其余按字面量(不锚定,部分命中即可)
|
||||
function globMatch(glob: string, s: string): boolean {
|
||||
try {
|
||||
const re = new RegExp(glob.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*').replace(/\\\?/g, '.'));
|
||||
return re.test(s);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface RoleForm {
|
||||
name: string;
|
||||
scopeMode: 'manual' | 'pattern';
|
||||
pattern: string;
|
||||
pipelineIds: number[];
|
||||
level: string;
|
||||
members: string[];
|
||||
}
|
||||
|
||||
const emptyForm: RoleForm = {
|
||||
name: '', scopeMode: 'manual', pattern: '', pipelineIds: [], level: 'operator', members: [],
|
||||
};
|
||||
|
||||
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
|
||||
|
||||
export default function PipelineRoles() {
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [form, setForm] = useState<RoleForm>(emptyForm);
|
||||
const [pipelineSearch, setPipelineSearch] = useState('');
|
||||
const [userSearch, setUserSearch] = useState('');
|
||||
|
||||
const rolesQuery = useQuery({ queryKey: ['pipeline-roles'], queryFn: apiClient.listPipelineRoles });
|
||||
const pipelinesQuery = useQuery({ queryKey: ['pipelines'], queryFn: apiClient.listPipelines });
|
||||
const usersQuery = useQuery({ queryKey: ['users'], queryFn: apiClient.listUsers });
|
||||
|
||||
const roles: any[] = rolesQuery.data ?? [];
|
||||
const pipelines: any[] = pipelinesQuery.data ?? [];
|
||||
const users: any[] = usersQuery.data ?? [];
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['pipeline-roles'] });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (f: RoleForm) => {
|
||||
const body = { name: f.name.trim(), scopeMode: f.scopeMode, pattern: f.pattern.trim(), pipelineIds: f.pipelineIds, level: f.level, members: f.members };
|
||||
return editing ? apiClient.updatePipelineRole(editing.id, body) : apiClient.createPipelineRole(body);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(editing ? '权限组已更新' : '权限组已创建');
|
||||
invalidate();
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: any) => apiClient.deletePipelineRole(id),
|
||||
onSuccess: () => {
|
||||
toast.success('权限组已删除');
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setPipelineSearch('');
|
||||
setUserSearch('');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (r: any) => {
|
||||
setEditing(r);
|
||||
setForm({
|
||||
name: r.name, scopeMode: r.scopeMode || 'manual', pattern: r.pattern || '',
|
||||
pipelineIds: (r.pipelineIds ?? []).map(Number), level: r.level, members: r.members ?? [],
|
||||
});
|
||||
setPipelineSearch('');
|
||||
setUserSearch('');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('请填写名称');
|
||||
return;
|
||||
}
|
||||
if (form.scopeMode === 'manual' && form.pipelineIds.length === 0) {
|
||||
toast.error('请至少勾选一条流水线');
|
||||
return;
|
||||
}
|
||||
if (form.scopeMode === 'pattern' && !form.pattern.trim()) {
|
||||
toast.error('请填写名称匹配规则,如 cex-*');
|
||||
return;
|
||||
}
|
||||
saveMutation.mutate(form);
|
||||
};
|
||||
|
||||
// 手动多选列表(带搜索)
|
||||
const filteredPipelines = useMemo(() => {
|
||||
const q = pipelineSearch.toLowerCase();
|
||||
return q ? pipelines.filter((p) => p.name?.toLowerCase().includes(q)) : pipelines;
|
||||
}, [pipelines, pipelineSearch]);
|
||||
|
||||
// 通配符实时预览:命中多少条流水线
|
||||
const patternPreview = useMemo(() => {
|
||||
if (form.scopeMode !== 'pattern' || !form.pattern.trim()) return null;
|
||||
const n = pipelines.filter(
|
||||
(p) => globMatch(form.pattern.trim(), p.name || '') || (p.jenkinsJobName && globMatch(form.pattern.trim(), p.jenkinsJobName)),
|
||||
).length;
|
||||
return n;
|
||||
}, [form.scopeMode, form.pattern, pipelines]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = userSearch.toLowerCase();
|
||||
return q ? users.filter((u: any) => u.username?.toLowerCase().includes(q) || u.nickname?.toLowerCase().includes(q)) : users;
|
||||
}, [users, userSearch]);
|
||||
|
||||
const pipelineNameById = useMemo(() => {
|
||||
const m = new Map<number, string>();
|
||||
pipelines.forEach((p) => m.set(Number(p.id), p.name));
|
||||
return m;
|
||||
}, [pipelines]);
|
||||
|
||||
const togglePipeline = (id: number) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
pipelineIds: f.pipelineIds.includes(id) ? f.pipelineIds.filter((x) => x !== id) : [...f.pipelineIds, id],
|
||||
}));
|
||||
const toggleMember = (username: string) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
members: f.members.includes(username) ? f.members.filter((x) => x !== username) : [...f.members, username],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
{/* Header */}
|
||||
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>流水线权限</h1>
|
||||
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
{roles.length}
|
||||
</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
未被授权的普通用户看不到任何流水线;授权后按等级「查看 / 发布 / 管理」放开
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
新建权限组
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{rolesQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : roles.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-16">
|
||||
<ShieldCheck className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>还没有权限组</div>
|
||||
<div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
新建一个权限组:勾选流水线 → 选择等级 → 添加成员,全程点选无需输入规则
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
{['名称', '授权范围', '等级', '成员', '匹配流水线', '操作'].map((h) => (
|
||||
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roles.map((r) => {
|
||||
const badge = levelBadgeStyle[r.level] ?? levelBadgeStyle.viewer;
|
||||
const memberText = (r.members ?? []).slice(0, 3).join('、') + ((r.members?.length ?? 0) > 3 ? ` 等 ${r.members.length} 人` : '');
|
||||
return (
|
||||
<tr key={r.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{r.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{r.scopeMode === 'pattern' ? (
|
||||
<span className="inline-flex items-center gap-1 text-[12px] font-mono px-2 py-[3px] rounded" style={{ background: '#fbefff', color: '#8250df' }}>
|
||||
<Wand2 className="w-3 h-3" />{r.pattern}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[12px] px-2 py-[3px] rounded" style={{ background: '#ddf4ff', color: '#0969da' }}>
|
||||
<ListChecks className="w-3 h-3" />{r.pipelineIds?.length ?? 0} 条流水线
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: badge.bg, color: badge.color }}>
|
||||
{levelLabel(r.level)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
||||
{r.members?.length ? memberText : <span style={{ color: 'var(--text-tertiary)' }}>—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
||||
{r.matchedPipelines ?? 0} 条
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openEdit(r)}>
|
||||
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除权限组「${r.name}」?成员将失去对应授权。`)) deleteMutation.mutate(r.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新建/编辑弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={(v) => { setDialogOpen(v); if (!v) setEditing(null); }}>
|
||||
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto custom-scrollbar">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[15px]">{editing ? '编辑权限组' : '新建权限组'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 名称 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称</Label>
|
||||
<Input
|
||||
className="h-8 text-[12px]"
|
||||
style={inputStyle}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="如:cex 发布组"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 授权范围 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>授权范围</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, scopeMode: 'manual' })}
|
||||
className="flex items-start gap-2 p-2.5 rounded-md border text-left transition-colors"
|
||||
style={
|
||||
form.scopeMode === 'manual'
|
||||
? { background: 'var(--accent-blue-light)', borderColor: 'var(--accent-blue)' }
|
||||
: { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }
|
||||
}
|
||||
>
|
||||
<ListChecks className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: form.scopeMode === 'manual' ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} />
|
||||
<div>
|
||||
<div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>选择流水线</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>勾选要授权的流水线,直观不踩坑</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, scopeMode: 'pattern' })}
|
||||
className="flex items-start gap-2 p-2.5 rounded-md border text-left transition-colors"
|
||||
style={
|
||||
form.scopeMode === 'pattern'
|
||||
? { background: 'var(--accent-blue-light)', borderColor: 'var(--accent-blue)' }
|
||||
: { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }
|
||||
}
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: form.scopeMode === 'pattern' ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} />
|
||||
<div>
|
||||
<div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
名称匹配 <span className="text-[10px] px-1 rounded" style={{ background: '#fff8c5', color: '#9a6700' }}>高级</span>
|
||||
</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>用 * 匹配一批,新建同名流水线自动纳入</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{form.scopeMode === 'manual' ? (
|
||||
<div className="rounded-md border" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 border-b" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<Search className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input
|
||||
value={pipelineSearch}
|
||||
onChange={(e) => setPipelineSearch(e.target.value)}
|
||||
placeholder="搜索流水线..."
|
||||
className="bg-transparent outline-none flex-1 text-[12px]"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button className="text-[11px] px-1.5 py-[2px] rounded" style={{ color: 'var(--accent-blue)' }} onClick={() => setForm({ ...form, pipelineIds: filteredPipelines.map((p) => Number(p.id)) })}>
|
||||
全选
|
||||
</button>
|
||||
<button className="text-[11px] px-1.5 py-[2px] rounded" style={{ color: 'var(--text-tertiary)' }} onClick={() => setForm({ ...form, pipelineIds: [] })}>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-44 overflow-y-auto custom-scrollbar">
|
||||
{filteredPipelines.map((p) => {
|
||||
const checked = form.pipelineIds.includes(Number(p.id));
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-[7px] text-left hover:bg-black/[0.02] transition-colors"
|
||||
onClick={() => togglePipeline(Number(p.id))}
|
||||
>
|
||||
{checked ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--accent-blue)' }} />
|
||||
) : (
|
||||
<Square className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
)}
|
||||
<span className="text-[12px] font-mono truncate" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
||||
{p.type === 'jenkins' && (
|
||||
<span className="text-[9px] px-1 rounded flex-shrink-0" style={{ background: '#fbefff', color: '#8250df' }}>Jenkins</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredPipelines.length === 0 && (
|
||||
<div className="px-3 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>没有匹配的流水线</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>
|
||||
已选 {form.pipelineIds.length} 条
|
||||
{form.pipelineIds.length > 0 && (
|
||||
<span className="inline-flex flex-wrap gap-1 ml-2 align-middle">
|
||||
{form.pipelineIds.slice(0, 5).map((id) => (
|
||||
<span key={id} className="inline-flex items-center gap-0.5 px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<span className="text-[10px] font-mono" style={{ color: 'var(--text-secondary)' }}>{pipelineNameById.get(id) ?? `#${id}`}</span>
|
||||
<X className="w-2.5 h-2.5 cursor-pointer" style={{ color: 'var(--text-tertiary)' }} onClick={() => togglePipeline(id)} />
|
||||
</span>
|
||||
))}
|
||||
{form.pipelineIds.length > 5 && <span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>…</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Input
|
||||
className="h-8 text-[12px] font-mono"
|
||||
style={inputStyle}
|
||||
value={form.pattern}
|
||||
onChange={(e) => setForm({ ...form, pattern: e.target.value })}
|
||||
placeholder="cex-*"
|
||||
/>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
只需用 <b>*</b> 代表任意字符,例如 <code>cex-*</code> 授权所有 cex- 开头的流水线,不需要正则表达式。
|
||||
</div>
|
||||
{patternPreview !== null && (
|
||||
<div className="text-[11px]" style={{ color: patternPreview > 0 ? '#1a7f37' : '#cf222e' }}>
|
||||
当前规则命中 {patternPreview} 条流水线{patternPreview === 0 ? '(请检查写法,如 cex-*)' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 权限等级 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>权限等级</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{LEVEL_OPTIONS.map((opt) => {
|
||||
const active = form.level === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, level: opt.value })}
|
||||
className="p-2.5 rounded-md border text-left transition-colors"
|
||||
style={
|
||||
active
|
||||
? { background: 'var(--accent-blue-light)', borderColor: 'var(--accent-blue)' }
|
||||
: { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }
|
||||
}
|
||||
>
|
||||
<div className="text-[12px] font-semibold" style={{ color: active ? 'var(--accent-blue)' : 'var(--text-primary)' }}>{opt.label}</div>
|
||||
<div className="text-[10px] mt-0.5 leading-4" style={{ color: 'var(--text-tertiary)' }}>{opt.desc}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 成员 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>成员({form.members.length} 人)</Label>
|
||||
<div className="rounded-md border" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 border-b" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<Search className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
placeholder="搜索用户..."
|
||||
className="bg-transparent outline-none flex-1 text-[12px]"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-36 overflow-y-auto custom-scrollbar">
|
||||
{filteredUsers.map((u: any) => {
|
||||
const checked = form.members.includes(u.username);
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-[6px] text-left hover:bg-black/[0.02] transition-colors"
|
||||
onClick={() => toggleMember(u.username)}
|
||||
>
|
||||
{checked ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--accent-blue)' }} />
|
||||
) : (
|
||||
<Square className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
)}
|
||||
<span className="text-[12px] font-mono" style={{ color: 'var(--text-primary)' }}>{u.username}</span>
|
||||
{u.nickname && <span className="text-[11px] truncate" style={{ color: 'var(--text-tertiary)' }}>{u.nickname}</span>}
|
||||
<span className="ml-auto text-[10px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
{u.role === 'admin' ? '管理员' : u.authSource === 'ldap' ? 'LDAP' : '本地'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className="px-3 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>没有匹配的用户</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white disabled:opacity-60"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
{saveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{editing ? '保存' : '创建'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ interface RunRecord {
|
||||
buildNumber?: number;
|
||||
externalUrl?: string;
|
||||
analysis?: string; // 失败时后端自动生成的 AI 失败分析
|
||||
canRetry?: boolean; // 后端按当前用户权限附加:执行等级及以上才允许重试
|
||||
}
|
||||
|
||||
interface RunStage {
|
||||
@@ -284,7 +285,7 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<Terminal className="w-3 h-3" />查看日志
|
||||
</button>
|
||||
{(record.status === 'failed' || record.status === 'cancelled') && (
|
||||
{(record.status === 'failed' || record.status === 'cancelled') && record.canRetry && (
|
||||
<button
|
||||
onClick={() => retryMutation.mutate()}
|
||||
disabled={retryMutation.isPending}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { apiClient } from '../lib/api'
|
||||
|
||||
// 会话信息(菜单分级、按钮权限的依据)。
|
||||
// 多个组件共享同一 ['me'] 查询缓存;登录成功后由 App invalidate 刷新。
|
||||
export function useSession() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: apiClient.me,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
return {
|
||||
username: data?.username ?? '',
|
||||
role: data?.role ?? '',
|
||||
isAdmin: !!data?.isAdmin,
|
||||
loading: isLoading,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user