Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f006464116 | ||
|
|
9103b13510 | ||
|
|
7090c8efc2 | ||
|
|
67aeafac19 | ||
|
|
ee59706ab9 | ||
|
|
935a29e2f1 | ||
|
|
2040cd042a | ||
|
|
b6b53c42a7 | ||
|
|
695c1c2357 | ||
|
|
ed88a18e03 | ||
|
|
8ee08e08da | ||
|
|
8bd323cc73 | ||
|
|
97193eb090 | ||
|
|
555934cc64 | ||
|
|
6841c1104a | ||
|
|
d9d1dd5e99 | ||
|
|
6e97b55015 | ||
|
|
a3b4637e19 |
No files matched your search
@@ -36,19 +36,27 @@ docker logs <容器名> | grep -A6 "已生成管理员账号"
|
|||||||
docker exec <容器名> cat /app/data/initial-admin-password.txt
|
docker exec <容器名> cat /app/data/initial-admin-password.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
登录校验接口:`POST /api/auth/login`(body: `{"username": "admin", "password": "..."}`)。
|
登录校验接口:`POST /api/auth/login`(body: `{"username": "admin", "password": "..."}`,返回签名 token,前端自动携带)。
|
||||||
密码使用 argon2id 哈希存储,请登录后立即修改。
|
密码使用 argon2id 哈希存储。启用 LDAP 后同名目录账号自动走 LDAP 验证(密码不落库)。
|
||||||
|
|
||||||
## 功能模块
|
## 功能模块
|
||||||
|
|
||||||
| 模块 | 说明 | 数据来源 |
|
| 模块 | 说明 | 数据来源 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
|
| 登录/会话 | 本地账号 + LDAP 账号统一登录(JWT 式签名 token,7 天) | gin API |
|
||||||
|
| 主题 | 深色/浅色/跟随系统,侧边栏快捷切换,多端同步 | gin API(持久化) |
|
||||||
| 流水线 | 列表 + 画布编辑器(Tekton 任务库、拖拽编排、连线、节点属性配置) | gin API(SQLite 持久化) |
|
| 流水线 | 列表 + 画布编辑器(Tekton 任务库、拖拽编排、连线、节点属性配置) | gin API(SQLite 持久化) |
|
||||||
| 模拟执行 | 触发运行后节点状态实时推进(pending→running→success)+ SSE 日志流 | gin API + 模拟执行引擎 |
|
| 模拟执行 | 触发运行后节点状态实时推进(pending→running→success)+ SSE 日志流 | gin API + 模拟执行引擎 |
|
||||||
| GitOps 部署 | ArgoCD 风格应用列表 / 资源树 / Diff | gin API(部分模拟) |
|
| AI 智能助手 | 全局悬浮对话:自然语言发布指令解析(流式输出、平台数据注入、规则兜底)、发布失败自动 AI 分析日志 | OpenAI 兼容 LLM(DeepSeek/GLM/Qwen/Kimi/Ollama 预设) |
|
||||||
| 插件市场 | 已安装 / 可安装 / 启停 / 卸载 | gin API(SQLite 持久化) |
|
| 飞书/Lark 通知 | 发布结果交互卡片(应用/分支/构建/状态/时间 + 构建详情按钮),支持加签 | gin API |
|
||||||
| 发布记录 | 运行历史、阶段、日志、Pod 详情 | 前端演示数据(待接入) |
|
| 用户管理 | 本地用户 CRUD + LDAP 用户导入(未启用 LDAP 时导入按钮隐藏),本地/LDAP 来源徽章 | gin API |
|
||||||
| 批量发布 / 环境 / 凭证 / 构建节点 / Jenkins 导入 / Webhook / 系统设置 | UI 演示 | 前端演示数据(待接入) |
|
| LDAP | 目录服务配置/测试连接/用户搜索导入、登录直连验证(密码不落库) | gin API(go-ldap) |
|
||||||
|
| 构建节点 | CRUD + 连通测试 + **dd 磁盘性能实测**(本地 exec / SSH 远程) | gin API |
|
||||||
|
| 工具配置 | Ubuntu 导向工具目录(apt/nvm 安装提示)、真实版本检测 | gin API |
|
||||||
|
| GitOps 部署 | ArgoCD 风格应用列表 / 资源树 / Diff | gin API |
|
||||||
|
| Jenkins | 服务器管理/Job 树/导入/真实构建触发与日志回传 | gin API(生产级客户端) |
|
||||||
|
| 批量发布 | 粘贴清单(opsx 风格解析)+ 手动勾选 + AI 对话触发 | gin API |
|
||||||
|
| 发布记录 | 运行历史、阶段、SSE 日志、Pod、失败 AI 分析、Jenkins 链接 | gin API |
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# FlowStack 接口文档
|
||||||
|
|
||||||
|
> 与当前后端代码对齐;所有 `/api/*` 需携带登录 Token,敏感字段只写不回显。
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 文档版本 | v0.4 |
|
||||||
|
| Base URL | `http://<host>:9527` |
|
||||||
|
| 鉴权 | `Authorization: Bearer <token>`(`POST /api/auth/login` 获取,7 天有效) |
|
||||||
|
| 技术栈 | Go gin + SQLite(GORM) |
|
||||||
|
|
||||||
|
**典型接入流程**:登录拿 token → 管理流水线/节点/凭证 → 触发运行 → SSE 看日志 → 查发布记录。
|
||||||
|
|
||||||
|
## 通用约定
|
||||||
|
|
||||||
|
- **鉴权**:除登录、健康检查(`/health`)、入站 Webhook(`/api/hooks/*`)、SSE 日志流外,所有接口需 Bearer Token;用户管理类接口需 admin 角色
|
||||||
|
- **错误格式**:`{"error": "中文错误信息"}`,HTTP 状态码语义化(400 参数错 / 401 未登录 / 403 权限 / 404 不存在 / 409 冲突)
|
||||||
|
- **敏感字段只写不回显**:凭证内容、API Key、Token、Bind 密码等提交后永不返回,响应以 `xxxSet` 布尔表示已配置;**编辑时留空 = 保持原值**
|
||||||
|
- **时间**:ISO 8601 带时区
|
||||||
|
|
||||||
|
## 认证
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/auth/login` | POST | 登录(本地 argon2id 或 LDAP 直连验证),返回签名 token |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:9527/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"xxx"}'
|
||||||
|
# → {"success":true,"token":"...","username":"admin","role":"admin","expiresAt":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
LDAP 用户名在目录验证通过后自动建号/同步(JIT);错误统一返回 401「用户名或密码错误」。
|
||||||
|
|
||||||
|
## 流水线
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/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=`;普通用户仅返回被授权流水线的,每条附 `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 与批量发布
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/ai/chat` | POST | AI 对话(JSON 返回;未配 LLM 降级规则匹配) |
|
||||||
|
| `/api/ai/chat/stream` | POST | 同上,SSE 流式(delta/done 事件) |
|
||||||
|
| `/api/ai/release` | POST | 按服务名批量触发(未导入的 Jenkins Job 自动建流水线) |
|
||||||
|
| `/api/batch/parse` | POST | 粘贴清单解析预览(纯规则) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:9527/api/ai/release \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"jobs":["aiops-backend","aiops-web"],"branch":"test"}'
|
||||||
|
# → {"triggered":[{"job":"aiops-backend","runId":"pr-..."}],"failed":[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 资源管理
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/hosts` / `/api/clusters` | CRUD | 主机 / Kubernetes 集群 |
|
||||||
|
| `/api/hosts/:id/test` / `/api/clusters/:id/test` | POST | 真实 TCP 拨号连通测试 |
|
||||||
|
| `/api/credentials` | CRUD | 凭证(内容 AES 加密只写;响应含 `contentSet`) |
|
||||||
|
| `/api/build-nodes` | CRUD | 构建节点 |
|
||||||
|
| `/api/build-nodes/:id/test` | POST | 连通测试(TCP 拨号 22 端口) |
|
||||||
|
| `/api/build-nodes/:id/disk-test` | POST | dd 磁盘性能实测(256MB,本机 exec 或 SSH 远程;返回读写 MB/s) |
|
||||||
|
| `/api/tools` | GET/PUT | 工具清单与安装策略 |
|
||||||
|
| `/api/tools/:id/check` | POST | 真实执行版本探测命令 |
|
||||||
|
| `/api/webhooks` | CRUD + `/:id/test` | 出站 Webhook(HMAC-SHA256 签名发送,配置入口:系统设置→消息通知) |
|
||||||
|
| `/api/hooks/pipeline/:id?token=` | POST | 入站触发流水线(免鉴权,token 校验) |
|
||||||
|
|
||||||
|
## Jenkins
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/jenkins-servers` | CRUD | 服务器管理(Token 加密只写) |
|
||||||
|
| `/api/jenkins-servers/:id/test` | POST | 真实连通测试(crumb + BasicAuth) |
|
||||||
|
| `/api/jenkins-servers/:id/jobs` | GET | 拉取 Job 树(folder 压平) |
|
||||||
|
| `/api/jenkins-servers/:id/import` | POST | 导入 Job 为 jenkins 类型流水线 |
|
||||||
|
|
||||||
|
## GitOps
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/gitops/apps` | GET | 应用列表(含同步/健康状态) |
|
||||||
|
| `/api/gitops/argo/apps` | GET | Argo 风格状态视图 |
|
||||||
|
| `/api/gitops/argo/sync` | POST | 触发同步(状态真实推进) |
|
||||||
|
| `/api/gitops/argo/:app/tree` / `/diff` | GET | 资源树 / 差异对比 |
|
||||||
|
|
||||||
|
## 用户与 LDAP
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/users` | GET/POST | 用户列表 / 创建本地用户(密码留空随机生成并一次性回显) |
|
||||||
|
| `/api/users/:id` | PUT/DELETE | 角色/信息更新 / 删除(不可删最后一个 admin) |
|
||||||
|
| `/api/users/:id/reset-password` | POST | 重置密码(LDAP 用户拒绝) |
|
||||||
|
| `/api/users/ldap-import` | POST | 批量导入(已 LDAP 跳过 / 本地同名转换 / 新建) |
|
||||||
|
| `/api/ldap/users` | POST | LDAP 用户预览(未启用返回 400) |
|
||||||
|
|
||||||
|
## 设置
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/settings` | GET/PUT | 单行设置:主题/通知/工具模式/AI/飞书/LDAP(敏感字段只写) |
|
||||||
|
| `/api/settings/test-llm` | POST | AI 连通测试(支持草稿值,apiKey 留空回退已存) |
|
||||||
|
| `/api/settings/test-lark` | POST | 飞书测试卡片 |
|
||||||
|
| `/api/settings/ldap/test` | POST | LDAP 测试(支持草稿 + 可选测试账号) |
|
||||||
|
| `/api/summary` | GET | 概览统计(侧边栏徽标) |
|
||||||
|
| `/api/docs` | GET | 本文档与使用指南 Markdown |
|
||||||
|
|
||||||
|
## 状态码速查
|
||||||
|
|
||||||
|
| 状态 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 400 | 参数错误 / 未启用对应功能 |
|
||||||
|
| 401 | 未登录 / token 过期 / 用户名或密码错误 |
|
||||||
|
| 403 | 需要 admin 角色 / 无该流水线的对应权限等级 |
|
||||||
|
| 404 | 资源不存在 |
|
||||||
|
| 409 | 名称重复等冲突 |
|
||||||
|
|
||||||
|
运行状态枚举:`pending → running → success | failed | cancelled`(Jenkins 队列阶段为 `queued`)。
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# FlowStack 使用指南
|
||||||
|
|
||||||
|
> 一句话读懂每个功能:**是什么 → 怎么用 → 实用技巧**。
|
||||||
|
|
||||||
|
## 快速开始(3 步跑通第一次发布)
|
||||||
|
|
||||||
|
1. **登录**:浏览器打开系统地址,用初始账号登录(初始密码在服务器 `data/initial-admin-password.txt` 或启动日志里)
|
||||||
|
2. **建流水线**:左侧「流水线」→ 新建流水线 → 填名称/仓库/部署方式 → 进入画布,从左侧任务库点选节点(源码→构建→部署)→ 点「保存」
|
||||||
|
3. **发布**:点「保存并运行」或「执行」,画布节点实时推进状态、底部日志滚动;结果可在「发布记录」查看,配置了飞书通知还会收到群卡片
|
||||||
|
|
||||||
|
## 流水线
|
||||||
|
|
||||||
|
**是什么**:把代码从拉取、构建、测试到部署的整个过程固化成一键可重复执行的流程。
|
||||||
|
|
||||||
|
**快速上手配置**:
|
||||||
|
- 画布编排:左侧 Task 任务库点击添加节点,拖拽摆位,从一个节点下方的连接点拖到另一个节点建立执行顺序
|
||||||
|
- 节点配置:点节点弹出右侧属性面板,修改参数(如 Git 仓库地址、镜像名)
|
||||||
|
- 基本信息 / 触发设置 / 变量和缓存 / 发布:编辑器顶部 Tab 切换,各自保存
|
||||||
|
- 触发方式:手动 / Webhook / Git Push / 定时(Cron),Webhook 触发会生成带 token 的调用地址,可直接配到 Git 仓库
|
||||||
|
|
||||||
|
**技巧**:
|
||||||
|
- `type=jenkins` 的流水线由 Jenkins 导入生成,执行时会真实触发 Jenkins 构建并回传日志
|
||||||
|
- 变量与缓存里定义的变量(如 `NODE_VERSION`)可在脚本中引用,配合 nvm 实现按次切换 Node 版本:`nvm install $NODE_VERSION`
|
||||||
|
- 流水线编辑器任务库支持搜索(按任务名/参数过滤)
|
||||||
|
|
||||||
|
## AI 智能助手
|
||||||
|
|
||||||
|
**是什么**:右下角悬浮的对话助手,用自然语言描述就能触发发布,配置 AI 后还能回答系统使用问题。
|
||||||
|
|
||||||
|
**怎么用**:
|
||||||
|
- 输入「发布 cex-agent-server、cex-agent-api 到 pre 分支」→ 出现发布计划卡片 → 确认发布 → 每个服务的运行编号(runId)直接回显
|
||||||
|
- 未在流水线里的 Jenkins Job 会自动创建流水线再触发
|
||||||
|
|
||||||
|
**技巧**:
|
||||||
|
- 在系统设置 → AI 助手里配置大模型(DeepSeek/GLM/Qwen/Kimi/Ollama 预设一键填地址);**不配置也能用**——自动降级为本地规则匹配模式
|
||||||
|
- 发布失败时 AI 自动分析日志尾部(结论/根因/建议),在发布记录展开可见
|
||||||
|
- 流式输出支持「停止」中断长回答
|
||||||
|
|
||||||
|
## 批量发布
|
||||||
|
|
||||||
|
**是什么**:一次把多个服务按顺序或并发发布。
|
||||||
|
|
||||||
|
**怎么用**(粘贴清单模式):
|
||||||
|
```
|
||||||
|
cex-agent-server
|
||||||
|
cex-agent-api
|
||||||
|
cex-agent-admin
|
||||||
|
```
|
||||||
|
粘贴多行服务名 → 选分支(快捷 chips:pre/test/main)→ 解析预览 → 开始串行/并发发布。
|
||||||
|
|
||||||
|
**技巧**:TAG 模式支持「服务名+版本号」成对粘贴(每行一个,或 `name=V1.0`);`#` 开头的行是注释;也可以用 AI 助手一句话触发批量发布。
|
||||||
|
|
||||||
|
## GitOps 部署
|
||||||
|
|
||||||
|
**是什么**:把 Git 仓库当作部署状态的唯一事实来源——改配置就是改 Git,系统负责让集群和仓库保持一致。
|
||||||
|
|
||||||
|
**怎么用**:
|
||||||
|
1. 在 Git 仓库里用 Kustomize/Helm 描述每个应用的部署参数
|
||||||
|
2. 本页登记应用(仓库/路径/集群/命名空间)
|
||||||
|
3. 「同步」把仓库声明推到集群;「资源树」看应用创建了哪些 K8s 资源;「差异对比」看 Git 与集群当前的不一致
|
||||||
|
|
||||||
|
**技巧**:开启自动同步后 Git 提交即自动部署;「待同步」状态说明集群和 Git 有差异,点同步即可收敛。
|
||||||
|
|
||||||
|
## Jenkins 集成
|
||||||
|
|
||||||
|
**是什么**:把现有 Jenkins Job 导入 FlowStack 统一管理,执行时真实触发 Jenkins 并把控制台日志实时回传。
|
||||||
|
|
||||||
|
**怎么用**:左侧「Jenkins导入」→ 添加服务器(地址+用户+API Token)→ 测试连接 → 拉取 Job 树 → 勾选导入 → 在流水线列表出现 `Jenkins` 标记的流水线。
|
||||||
|
|
||||||
|
**技巧**:Token 只写不回显,编辑留空表示不修改;批量发布/AI 助手提到未导入的 Job 会自动创建流水线,无需先来导入页。
|
||||||
|
|
||||||
|
## 构建节点
|
||||||
|
|
||||||
|
**是什么**:执行构建任务的机器,支持标签调度、并发上限和健康检查。
|
||||||
|
|
||||||
|
**怎么用**:添加节点(名称/IP/标签/并发数)→「测试连接」验证可达 →「磁盘性能」跑 dd 实测读写速度(写入/读取 MB/s 直接显示在卡片上)。
|
||||||
|
|
||||||
|
**技巧**:
|
||||||
|
- 本机节点(127.0.0.1)磁盘测试直接本地执行;远程节点需先关联 SSH 凭证
|
||||||
|
- 磁盘速度颜色分级:绿 >200MB/s(SSD 级)、黄 50-200、红 <50(需关注)
|
||||||
|
|
||||||
|
## 工具配置
|
||||||
|
|
||||||
|
**是什么**:构建环境的工具清单与安装策略,Ubuntu 导向。
|
||||||
|
|
||||||
|
**技巧**:
|
||||||
|
- 每个工具带一键复制的安装命令(`apt install ...` / nvm 脚本)
|
||||||
|
- Node 用 nvm 管理:默认版本在此设置,流水线构建时 `nvm install $NODE_VERSION` 灵活切换
|
||||||
|
- 「检测」真实执行版本探测命令(如 `java -version`);「安装」是保守策略——只检测不自动下载
|
||||||
|
- `deploy/Dockerfile.buildnode` 一键构建 Ubuntu 构建节点镜像
|
||||||
|
|
||||||
|
## 飞书 / Lark 通知与 Webhook
|
||||||
|
|
||||||
|
**是什么**:发布成功/失败自动向群推送结果卡片(应用/分支/构建/状态/时间 + 「查看构建详情」按钮直达 Jenkins)。
|
||||||
|
|
||||||
|
**怎么用**:系统设置 → 消息通知 → 填群机器人 Webhook(和可选的签名密钥)→「发送测试卡片」验证。
|
||||||
|
|
||||||
|
**技巧**:
|
||||||
|
- 单条流水线可在「基本信息」里单独填通知 Webhook,优先于全局配置
|
||||||
|
- 通用 Webhook 回调(发给第三方系统,HMAC-SHA256 签名)也在系统设置 → 消息通知里配置
|
||||||
|
|
||||||
|
## 用户与 LDAP
|
||||||
|
|
||||||
|
**是什么**:本地账号 + 企业目录(LDAP/AD)账号统一登录。
|
||||||
|
|
||||||
|
**怎么用**:
|
||||||
|
- 系统设置 → 用户目录:启用 LDAP,填服务器/Bind DN/Base DN → 测试连接(可带测试账号验证)→ 保存
|
||||||
|
- 用户管理页出现「从 LDAP 导入」→ 勾选导入;列表来源徽章区分「本地账号」和「LDAP 导入」
|
||||||
|
|
||||||
|
**技巧**:
|
||||||
|
- LDAP 用户密码在目录中管理(不落库),首登自动建号
|
||||||
|
- 未启用 LDAP 时导入按钮自动隐藏
|
||||||
|
|
||||||
|
## 环境与凭证
|
||||||
|
|
||||||
|
**是什么**:环境管理登记主机与 Kubernetes 集群(真实连通测试);凭证集中保管敏感信息。
|
||||||
|
|
||||||
|
**技巧**:
|
||||||
|
- 凭证内容 AES 加密存储、只写不回显,编辑留空=不修改
|
||||||
|
- SSH 凭证关联构建节点后即可远程磁盘测试
|
||||||
|
|
||||||
|
## 流水线权限
|
||||||
|
|
||||||
|
**是什么**:按流水线控制「谁能看、谁能发布、谁能改」。未被授权的普通用户看不到任何流水线与发布记录(默认拒绝);管理员始终拥有全部权限。
|
||||||
|
|
||||||
|
**三级权限**:
|
||||||
|
|
||||||
|
| 等级 | 能做什么 |
|
||||||
|
|---|---|
|
||||||
|
| 查看 | 看流水线、发布记录、发布成功/失败日志(只读,不能构建) |
|
||||||
|
| 发布 | 查看 + 触发构建、批量发布、失败重试 |
|
||||||
|
| 管理 | 发布 + 编辑流水线配置、管理该流水线的成员授权 |
|
||||||
|
|
||||||
|
**怎么授权(管理员,系统 → 流水线权限)**:
|
||||||
|
- 新建权限组 → **选择流水线**:直接勾选要授权的流水线(推荐,全程点选)
|
||||||
|
- 或 **名称匹配(高级)**:只写 `cex-*` 这类通配符(`*` 代表任意字符,无需正则),一次授权整批,之后新建的同名流水线自动纳入
|
||||||
|
- 选好权限等级,勾选成员,保存即生效
|
||||||
|
- 也可以在单条流水线里:编辑器顶栏「成员」按钮,给个别人单独授权(作为权限组的例外补充)
|
||||||
|
|
||||||
|
**普通用户能看什么**:
|
||||||
|
- 菜单只剩「发布」组(流水线/批量发布/发布记录)和「文档中心」;资源、集成、GitOps、系统管理类菜单全部隐藏(直接输 URL 也会被拦回)
|
||||||
|
- 流水线列表只显示被授权的;按钮按等级显隐:查看级只有只读入口,发布级出现「执行/保存并运行/重试」,管理级才有「保存/成员」
|
||||||
|
- 新建/删除流水线、凭证/主机/集群/构建节点等资源管理仅管理员
|
||||||
|
|
||||||
|
**技巧**:
|
||||||
|
- AI 助手批量发布同样受控:对未授权的 Job 会在结果里明确标注「无该流水线的执行权限」
|
||||||
|
- 构建日志 SSE 已要求登录(`?token=` 传递会话),未登录直接访问返回 401
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**初始密码在哪**:服务器 `data/initial-admin-password.txt` 或 `grep -A6 "已生成管理员账号" logs/flowstack.log`
|
||||||
|
|
||||||
|
**默认端口**:9527,可用 `PORT=xxxx ./run.sh` 覆盖
|
||||||
|
|
||||||
|
**页面打不开/跳转循环**:访问一次 `http://<地址>/?_clear=1` 清除浏览器缓存的旧重定向
|
||||||
|
|
||||||
|
**深色模式**:侧边栏底部太阳/月亮按钮,或系统设置 → 通用 → 主题模式(跟随系统可选)
|
||||||
|
|
||||||
|
**API 调用**:见「接口文档」页——所有 `/api/*` 需带登录 token(`Authorization: Bearer <token>`)
|
||||||
+12
-9
@@ -1,16 +1,18 @@
|
|||||||
module flowstack
|
module flowstack
|
||||||
|
|
||||||
go 1.24
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-contrib/cors v1.7.6
|
github.com/gin-contrib/cors v1.7.6
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
golang.org/x/crypto v0.40.0
|
github.com/go-ldap/ldap/v3 v3.4.15
|
||||||
|
golang.org/x/crypto v0.54.0
|
||||||
gorm.io/gorm v1.30.0
|
gorm.io/gorm v1.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
@@ -18,12 +20,13 @@ require (
|
|||||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
@@ -40,12 +43,12 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.57.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.27.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
golang.org/x/tools v0.34.0 // indirect
|
golang.org/x/tools v0.47.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
|||||||
+40
-16
@@ -1,3 +1,7 @@
|
|||||||
|
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
@@ -21,6 +25,10 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
|
|||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -38,8 +46,22 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
|||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||||
|
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||||
|
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -85,21 +107,23 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
|||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func Init() error {
|
|||||||
|
|
||||||
if err := DB.AutoMigrate(
|
if err := DB.AutoMigrate(
|
||||||
&model.User{},
|
&model.User{},
|
||||||
|
&model.PipelineRole{}, &model.PipelineMember{},
|
||||||
&model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{},
|
&model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{},
|
||||||
&model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{},
|
&model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{},
|
||||||
&model.Application{}, &model.Plugin{},
|
&model.Application{}, &model.Plugin{},
|
||||||
@@ -106,9 +107,9 @@ func seedDemoData() {
|
|||||||
|
|
||||||
log.Println("[seed] 首次启动,写入示例数据…")
|
log.Println("[seed] 首次启动,写入示例数据…")
|
||||||
|
|
||||||
// ---- 流水线 1:aiops-backend(Go + K8s) ----
|
// ---- 流水线 1:aiops-backend(Go + Kubernetes) ----
|
||||||
backend := model.Pipeline{
|
backend := model.Pipeline{
|
||||||
Name: "aiops-backend", Description: "AIOps 后端服务(Go + K8s 滚动发布)",
|
Name: "aiops-backend", Description: "AIOps 后端服务(Go + Kubernetes 滚动发布)",
|
||||||
Type: "native", SourceRepo: "https://gitee.com/attacker/aiops.git", SourceBranch: "master",
|
Type: "native", SourceRepo: "https://gitee.com/attacker/aiops.git", SourceBranch: "master",
|
||||||
DeployType: "k8s", Env: "production",
|
DeployType: "k8s", Env: "production",
|
||||||
}
|
}
|
||||||
@@ -203,9 +204,9 @@ func seedResources() {
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
hb := time.Now().Add(-10 * time.Second)
|
hb := time.Now().Add(-10 * time.Second)
|
||||||
DB.Create([]model.BuildNode{
|
DB.Create([]model.BuildNode{
|
||||||
{Name: "build-host-01", IP: "10.0.8.11", Labels: `["docker","go","java"]`, MaxConcurrency: 4, Status: "online", CurrentJobs: 1, LastHeartbeat: &hb},
|
{Name: "build-host-01", IP: "10.0.8.11", Labels: `["docker","go","java"]`, MaxConcurrency: 4, Status: "online", CurrentJobs: 1, DiskInfo: "78G/200G", LastHeartbeat: &hb},
|
||||||
{Name: "build-host-02", IP: "10.0.8.12", Labels: `["docker","nodejs"]`, MaxConcurrency: 2, Status: "busy", CurrentJobs: 2, LastHeartbeat: &hb},
|
{Name: "build-host-02", IP: "10.0.8.12", Labels: `["docker","nodejs"]`, MaxConcurrency: 2, Status: "busy", CurrentJobs: 2, DiskInfo: "156G/500G", LastHeartbeat: &hb},
|
||||||
{Name: "build-host-03", IP: "10.0.8.13", Labels: `["python"]`, MaxConcurrency: 2, Status: "offline", LastHeartbeat: &hb},
|
{Name: "build-host-03", IP: "10.0.8.13", Labels: `["python"]`, MaxConcurrency: 2, Status: "offline", DiskInfo: "45G/100G", LastHeartbeat: &hb},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
DB.Model(&model.WebhookEndpoint{}).Count(&n)
|
DB.Model(&model.WebhookEndpoint{}).Count(&n)
|
||||||
@@ -224,7 +225,7 @@ func seedResources() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedTools 工具清单(CheckCommand 为真实版本探测命令,安装走保守检测策略)
|
// seedTools 工具清单(Ubuntu 构建环境导向;Node 由 nvm 管理默认版本,构建时可按需 nvm install)
|
||||||
func seedTools() {
|
func seedTools() {
|
||||||
var n int64
|
var n int64
|
||||||
DB.Model(&model.DeployTool{}).Count(&n)
|
DB.Model(&model.DeployTool{}).Count(&n)
|
||||||
@@ -232,11 +233,12 @@ func seedTools() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
DB.Create([]model.DeployTool{
|
DB.Create([]model.DeployTool{
|
||||||
{ToolKey: "jdk", DisplayName: "JDK", Category: "build", Version: "17", InstallMode: "preinstalled", Path: "/usr/lib/jvm/java-17", CheckCommand: "java -version", SortOrder: 1},
|
{ToolKey: "jdk", DisplayName: "JDK", Category: "build", Version: "17", InstallMode: "preinstalled", Path: "/usr/lib/jvm/java-17-openjdk-amd64", CheckCommand: "java -version", InstallHint: "apt update && apt install -y openjdk-17-jdk", SortOrder: 1},
|
||||||
{ToolKey: "maven", DisplayName: "Maven", Category: "build", Version: "3.9", InstallMode: "lazy", Path: "/opt/maven", CheckCommand: "mvn -version", SortOrder: 2},
|
{ToolKey: "maven", DisplayName: "Maven", Category: "build", Version: "3.8", InstallMode: "lazy", Path: "/usr/share/maven", CheckCommand: "mvn -version", InstallHint: "apt install -y maven", SortOrder: 2},
|
||||||
{ToolKey: "golang", DisplayName: "Golang", Category: "build", Version: "1.24", InstallMode: "preinstalled", Path: "/usr/local/go", CheckCommand: "go version", SortOrder: 3},
|
{ToolKey: "golang", DisplayName: "Golang", Category: "build", Version: "1.24", InstallMode: "preinstalled", Path: "/usr/local/go", CheckCommand: "go version", InstallHint: "apt install -y golang-1.24-go 或官方 tar 包解压至 /usr/local/go", SortOrder: 3},
|
||||||
{ToolKey: "nodejs", DisplayName: "Node.js", Category: "build", Version: "20", InstallMode: "lazy", Path: "/usr/local/nvm", CheckCommand: "node -v", SortOrder: 4},
|
{ToolKey: "nvm", DisplayName: "nvm(Node 版本管理)", Category: "build", Version: "0.40", InstallMode: "preinstalled", Path: "$HOME/.nvm", CheckCommand: `. "$HOME/.nvm/nvm.sh" && nvm --version`, InstallHint: "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash", SortOrder: 4},
|
||||||
{ToolKey: "git", DisplayName: "Git", Category: "build", Version: "2.40", InstallMode: "preinstalled", Path: "/usr/bin/git", CheckCommand: "git --version", SortOrder: 5},
|
{ToolKey: "nodejs", DisplayName: "Node.js(nvm 管理)", Category: "build", Version: "20", InstallMode: "lazy", Path: "$HOME/.nvm/versions/node", CheckCommand: "node -v", InstallHint: "nvm install 20 && nvm alias default 20(构建时可用 nvm install 18 按需切版本)", SortOrder: 5},
|
||||||
{ToolKey: "docker-cli", DisplayName: "Docker CLI", Category: "deploy", Version: "26", InstallMode: "lazy", Path: "/usr/bin/docker", CheckCommand: "docker --version", SortOrder: 6},
|
{ToolKey: "git", DisplayName: "Git", Category: "build", Version: "2.40", InstallMode: "preinstalled", Path: "/usr/bin/git", CheckCommand: "git --version", InstallHint: "apt install -y git", SortOrder: 6},
|
||||||
|
{ToolKey: "docker-cli", DisplayName: "Docker CLI", Category: "deploy", Version: "26", InstallMode: "lazy", Path: "/usr/bin/docker", CheckCommand: "docker --version", InstallHint: "apt install -y docker.io", SortOrder: 7},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -362,6 +362,7 @@ func AiRelease(hub *Hub) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
autoCreate := body.AutoCreate == nil || *body.AutoCreate
|
autoCreate := body.AutoCreate == nil || *body.AutoCreate
|
||||||
|
username, isAdmin := currentUser(c)
|
||||||
|
|
||||||
candidates := allCandidates()
|
candidates := allCandidates()
|
||||||
type triggered struct {
|
type triggered struct {
|
||||||
@@ -378,12 +379,24 @@ func AiRelease(hub *Hub) gin.HandlerFunc {
|
|||||||
var failList []failed
|
var failList []failed
|
||||||
|
|
||||||
for _, name := range body.Jobs {
|
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)
|
p, created, err := resolvePipeline(name, candidates, autoCreate, body.JenkinsServerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failList = append(failList, failed{Job: name, Reason: err.Error()})
|
failList = append(failList, failed{Job: name, Reason: err.Error()})
|
||||||
continue
|
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 {
|
if err != nil {
|
||||||
failList = append(failList, failed{Job: name, Reason: "触发失败: " + err.Error()})
|
failList = append(failList, failed{Job: name, Reason: "触发失败: " + err.Error()})
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/llm"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildPlatformContext 平台实时数据注入(降低 AI 幻觉):流水线概况 + 最近运行
|
||||||
|
func buildPlatformContext() string {
|
||||||
|
var pipelines []model.Pipeline
|
||||||
|
database.DB.Find(&pipelines)
|
||||||
|
var runs []model.PipelineRun
|
||||||
|
database.DB.Order("started_at DESC").Limit(5).Find(&runs)
|
||||||
|
running := 0
|
||||||
|
for _, r := range runs {
|
||||||
|
if r.Status == "running" || r.Status == "pending" {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "【平台实时数据】\n流水线总数: %d(运行中 %d)\n", len(pipelines), running)
|
||||||
|
if len(runs) > 0 {
|
||||||
|
b.WriteString("最近运行:\n")
|
||||||
|
for _, r := range runs {
|
||||||
|
fmt.Fprintf(&b, "- %s %s %s %s %s\n", r.PipelineName, r.RunID, r.Branch, r.Status, fmtDurMs(r.DurationMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtDurMs(ms int64) string {
|
||||||
|
if ms <= 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%ds", ms/1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AiChatStream POST /api/ai/chat/stream —— 流式对话(SSE)
|
||||||
|
func AiChatStream(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Messages []ChatMsg `json:"messages" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Messages) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "messages 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var lastUser string
|
||||||
|
for i := len(body.Messages) - 1; i >= 0; i-- {
|
||||||
|
if body.Messages[i].Role == "user" {
|
||||||
|
lastUser = body.Messages[i].Content
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastUser == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "没有用户消息"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := allCandidates()
|
||||||
|
names := make([]string, 0, len(candidates))
|
||||||
|
if len(candidates) > 200 {
|
||||||
|
candidates = candidates[:200]
|
||||||
|
}
|
||||||
|
for _, cd := range candidates {
|
||||||
|
names = append(names, cd.Name)
|
||||||
|
}
|
||||||
|
ruleRes := parseByRules(lastUser, candidates)
|
||||||
|
|
||||||
|
client := llmClientFromSettings()
|
||||||
|
if client == nil {
|
||||||
|
// 未配置 LLM:直接返回规则结果(不流式)
|
||||||
|
reply, action := ruleReply(ruleRes, candidates)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"reply": reply, "viaAI": false, "action": action})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
flush := func() {
|
||||||
|
if f, ok := c.Writer.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit := func(event string, data any) {
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, b)
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
system := fmt.Sprintf(`你是 FlowStack 发布系统的智能发布助手。用户会用自然语言描述要发布的服务。
|
||||||
|
%s
|
||||||
|
可发布的服务清单(流水线与 Jenkins Job 名称)如下:
|
||||||
|
%s
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 若用户意图是发布/构建服务:只输出 JSON(不要 markdown 代码块、不要多余文字):
|
||||||
|
{"reply":"一句中文确认语","jobs":["服务名1"],"branch":"分支或留空","env":"环境或留空"}
|
||||||
|
jobs 必须来自上面的清单按原文匹配;用户提到的服务不在清单时 jobs 留空并在 reply 中说明。
|
||||||
|
- 其他问题(如询问状态、概念解释):用简洁中文自然语言回答,禁止编造清单中不存在的服务或字段。`,
|
||||||
|
buildPlatformContext(), strings.Join(names, "\n"))
|
||||||
|
|
||||||
|
emit("start", gin.H{"viaAI": true})
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
full, err := client.ChatStream(ctx, system, lastUser, func(d llm.StreamDelta) {
|
||||||
|
if d.Content != "" || d.Reasoning != "" {
|
||||||
|
emit("delta", gin.H{"content": d.Content, "reasoning": d.Reasoning})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// LLM 失败:降级规则结果
|
||||||
|
reply, action := ruleReply(ruleRes, candidates)
|
||||||
|
emit("done", gin.H{"reply": reply + "\n(AI 调用失败已降级本地规则: " + err.Error() + ")", "viaAI": false, "action": action})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, action := parseLLMJson(full, candidates)
|
||||||
|
if action == nil && len(ruleRes.Jobs) > 0 {
|
||||||
|
// LLM 没识别出发布指令但规则识别到了,合并规则结果
|
||||||
|
_, action = ruleReply(ruleRes, candidates)
|
||||||
|
}
|
||||||
|
emit("done", gin.H{"reply": reply, "viaAI": true, "action": action})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleReply(ruleRes ruleParseResult, candidates []Candidate) (string, *aiReleaseAction) {
|
||||||
|
if len(ruleRes.Jobs) == 0 {
|
||||||
|
return "未识别到要发布的服务。请直接告诉我服务名,例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。", nil
|
||||||
|
}
|
||||||
|
jobs := make([]aiActionJob, 0, len(ruleRes.Jobs))
|
||||||
|
for _, name := range ruleRes.Jobs {
|
||||||
|
m := matchCandidate(name, candidates)
|
||||||
|
jobs = append(jobs, aiActionJob{Name: name, Matched: m != nil, Source: sourceOf(m)})
|
||||||
|
}
|
||||||
|
action := &aiReleaseAction{Type: "release", Jobs: jobs, Branch: ruleRes.Branch, Env: ruleRes.Env}
|
||||||
|
return fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(jobs), branchSuffix(ruleRes.Branch)), action
|
||||||
|
}
|
||||||
|
|
||||||
|
// analyzeFailure 发布失败时用 LLM 分析日志尾部(opsx-loki AnalyzeJenkins 同款 prompt)
|
||||||
|
func analyzeFailure(run model.PipelineRun) {
|
||||||
|
client := llmClientFromSettings()
|
||||||
|
if client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var logs []model.LogEntry
|
||||||
|
database.DB.Where("run_id = ?", run.ID).Order("timestamp DESC").Limit(100).Find(&logs)
|
||||||
|
if len(logs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 倒序取出后反转为正序
|
||||||
|
for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
logs[i], logs[j] = logs[j], logs[i]
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, l := range logs {
|
||||||
|
b.WriteString(l.Message)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
system := `你是资深发布助手。根据流水线执行日志分析发布失败原因。用简洁中文输出,结构固定:
|
||||||
|
1. 结论:一句话说明失败原因
|
||||||
|
2. 失败阶段或关键步骤
|
||||||
|
3. 根因(如编译错误、依赖缺失、测试失败、镜像推送失败、部署超时)
|
||||||
|
4. 处理建议(1-3 条,可执行)
|
||||||
|
不要复述整段日志。`
|
||||||
|
user := fmt.Sprintf("流水线: %s\n分支: %s\n状态: %s\n\n日志尾部:\n%s", run.PipelineName, run.Branch, run.Status, b.String())
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
reply, err := client.Chat(ctx, system, user)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("analysis", reply)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -10,7 +20,112 @@ import (
|
|||||||
"flowstack/internal/model"
|
"flowstack/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Login POST /api/auth/login {username, password}
|
const tokenTTL = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// sessionSecret 从 data/encryption.key 派生会话签名密钥
|
||||||
|
func sessionSecret() []byte {
|
||||||
|
key := os.Getenv("FLOWSTACK_ENCRYPTION_KEY")
|
||||||
|
if key == "" {
|
||||||
|
if data, err := os.ReadFile(filepath.Join(database.DataDir, "encryption.key")); err == nil && len(data) == 32 {
|
||||||
|
key = string(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte("flowstack-session:" + key))
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignToken 签发 HMAC 无状态 token:base64(user|exp|sig)
|
||||||
|
func SignToken(username string) (string, time.Time) {
|
||||||
|
exp := time.Now().Add(tokenTTL)
|
||||||
|
payload := username + "|" + strconv.FormatInt(exp.Unix(), 10)
|
||||||
|
mac := hmac.New(sha256.New, sessionSecret())
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
sig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(payload + "|" + sig)), exp
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyToken 校验 token,返回用户名
|
||||||
|
func VerifyToken(token string) (string, error) {
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("token 格式错误")
|
||||||
|
}
|
||||||
|
parts := strings.Split(string(raw), "|")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return "", fmt.Errorf("token 结构错误")
|
||||||
|
}
|
||||||
|
payload := parts[0] + "|" + parts[1]
|
||||||
|
mac := hmac.New(sha256.New, sessionSecret())
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
expect := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
if !hmac.Equal([]byte(expect), []byte(parts[2])) {
|
||||||
|
return "", fmt.Errorf("token 签名校验失败")
|
||||||
|
}
|
||||||
|
expUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
if err != nil || time.Now().Unix() > expUnix {
|
||||||
|
return "", fmt.Errorf("token 已过期")
|
||||||
|
}
|
||||||
|
return parts[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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/") {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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(token)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效,请重新登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("username", user.Username)
|
||||||
|
c.Set("role", user.Role)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if c.GetString("role") != "admin" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login POST /api/auth/login —— 本地 argon2id 与 LDAP 分流认证
|
||||||
func Login(c *gin.Context) {
|
func Login(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required"`
|
||||||
@@ -21,16 +136,33 @@ func Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1) 本地用户:AuthSource != ldap 才走本地口令
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := database.DB.Where("username = ?", body.Username).First(&user).Error; err != nil {
|
hasLocal := database.DB.Where("username = ?", body.Username).First(&user).Error == nil
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
if hasLocal && user.AuthSource != "ldap" {
|
||||||
return
|
|
||||||
}
|
|
||||||
if !crypto.VerifyPassword(body.Password, user.Password) {
|
if !crypto.VerifyPassword(body.Password, user.Password) {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
token, exp := SignToken(user.Username)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true, "username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
"success": true, "token": token, "expiresAt": exp,
|
||||||
|
"username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) LDAP:目录验证成功即登录(首次自动建号/同步)
|
||||||
|
if entry, err := ldapVerifyUser(body.Username, body.Password); err == nil {
|
||||||
|
user := syncLDAPUser(entry)
|
||||||
|
token, exp := SignToken(user.Username)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true, "token": token, "expiresAt": exp,
|
||||||
|
"username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一错误,不泄露用户是否存在
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
"flowstack/internal/sshexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ddFileSize = ".fs-disktest"
|
||||||
|
ddSizeMB = 256
|
||||||
|
ddTimeout = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type cmdRunner func(cmd string) (string, error)
|
||||||
|
|
||||||
|
func localRunner(cmd string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), ddTimeout)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, "sh", "-c", cmd).CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDDMBps 兼容两种 dd 输出:
|
||||||
|
// Linux: "268435456 bytes (268 MB, 256 MiB) copied, 1.2 s, 224 MB/s"
|
||||||
|
// macOS: "268435456 bytes transferred in 1.234 secs (217543210 bytes/sec)"
|
||||||
|
var (
|
||||||
|
linuxSpeedRe = regexp.MustCompile(`([\d.]+)\s*([kMGT]?)B/s`)
|
||||||
|
macSpeedRe = regexp.MustCompile(`\(([\d,]+)\s*bytes/sec\)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseDDMBps(out string) float64 {
|
||||||
|
if ms := linuxSpeedRe.FindAllStringSubmatch(out, -1); len(ms) > 0 {
|
||||||
|
m := ms[len(ms)-1]
|
||||||
|
v, err := strconv.ParseFloat(m[1], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch m[2] {
|
||||||
|
case "k", "K":
|
||||||
|
v /= 1024
|
||||||
|
case "G":
|
||||||
|
v *= 1024
|
||||||
|
case "T":
|
||||||
|
v *= 1024 * 1024
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if m := macSpeedRe.FindStringSubmatch(out); m != nil {
|
||||||
|
v, err := strconv.ParseFloat(strings.ReplaceAll(m[1], ",", ""), 64)
|
||||||
|
if err == nil {
|
||||||
|
return v / 1e6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildNodeDisk POST /api/build-nodes/:id/disk-test —— dd 实测磁盘读写
|
||||||
|
func TestBuildNodeDisk(c *gin.Context) {
|
||||||
|
var n model.BuildNode
|
||||||
|
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地节点直接 exec,否则 SSH(需要凭证)
|
||||||
|
isLocal := n.IP == "127.0.0.1" || n.IP == "localhost" || n.IP == "::1"
|
||||||
|
var run cmdRunner
|
||||||
|
mode := "ssh"
|
||||||
|
if isLocal {
|
||||||
|
run = localRunner
|
||||||
|
mode = "local"
|
||||||
|
} else {
|
||||||
|
if n.CredentialID == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "远程节点需先在编辑中关联 SSH 凭证"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cred model.Credential
|
||||||
|
if err := database.DB.First(&cred, *n.CredentialID).Error; err != nil || cred.Content == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "SSH 凭证不存在或未设置内容"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plain, err := crypto.DecryptField(cred.Content)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "凭证解密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cc struct {
|
||||||
|
PrivateKey string `json:"private_key"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
SSHUser string `json:"ssh_user"`
|
||||||
|
SSHPort int `json:"ssh_port"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(plain), &cc); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "凭证内容格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := sshexec.Config{Host: n.IP, Port: cc.SSHPort, User: cc.SSHUser, Password: cc.Password, PrivateKey: cc.PrivateKey}
|
||||||
|
run = func(cmd string) (string, error) {
|
||||||
|
return sshexec.Run(cfg, cmd, ddTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws := n.WorkspacePath
|
||||||
|
if ws == "" {
|
||||||
|
ws = "/data/flowstack-workspace"
|
||||||
|
}
|
||||||
|
file := ws + "/" + ddFileSize
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
if _, err := run(fmt.Sprintf("mkdir -p '%s'", ws)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "创建工作空间失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _, _ = run(fmt.Sprintf("rm -f '%s'", file)) }()
|
||||||
|
|
||||||
|
// 写测试:direct 优先(绕过缓存),失败降级普通模式
|
||||||
|
writeCmd := fmt.Sprintf("dd if=/dev/zero of='%s' bs=1m count=%d oflag=direct 2>&1", file, ddSizeMB)
|
||||||
|
writeOut, err := run(writeCmd)
|
||||||
|
direct := err == nil
|
||||||
|
if err != nil || parseDDMBps(writeOut) == 0 {
|
||||||
|
writeOut, err = run(fmt.Sprintf("dd if=/dev/zero of='%s' bs=1m count=%d 2>&1", file, ddSizeMB))
|
||||||
|
direct = false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "写入测试失败: " + firstLine(writeOut, err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeMBps := parseDDMBps(writeOut)
|
||||||
|
|
||||||
|
// 读测试
|
||||||
|
readCmd := fmt.Sprintf("dd if='%s' of=/dev/null bs=1m 2>&1", file)
|
||||||
|
if direct {
|
||||||
|
readCmd = fmt.Sprintf("dd if='%s' of=/dev/null bs=1m iflag=direct 2>&1", file)
|
||||||
|
}
|
||||||
|
readOut, err := run(readCmd)
|
||||||
|
if err != nil && direct {
|
||||||
|
readOut, err = run(fmt.Sprintf("dd if='%s' of=/dev/null bs=1m 2>&1", file))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "读取测试失败: " + firstLine(readOut, err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
readMBps := parseDDMBps(readOut)
|
||||||
|
|
||||||
|
if direct {
|
||||||
|
mode += "/direct"
|
||||||
|
} else {
|
||||||
|
mode += "/cache"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回写节点
|
||||||
|
now := time.Now()
|
||||||
|
database.DB.Model(&n).Updates(map[string]any{
|
||||||
|
"disk_write_mbps": writeMBps, "disk_read_mbps": readMBps,
|
||||||
|
"disk_test_mode": mode, "disk_tested_at": &now,
|
||||||
|
})
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true, "writeMBps": writeMBps, "readMBps": readMBps,
|
||||||
|
"mode": mode, "elapsedMs": time.Since(start).Milliseconds(),
|
||||||
|
"message": fmt.Sprintf("写入 %.0f MB/s · 读取 %.0f MB/s(%s)", writeMBps, readMBps, map[bool]string{true: "direct 直测", false: "含缓存"}[direct]),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDocs 返回文档端点:运行目录 docs/ 下同名文件优先(可热更新),否则用内嵌副本。
|
||||||
|
func GetDocs(docsFS embed.FS) gin.HandlerFunc {
|
||||||
|
readDoc := func(name string) string {
|
||||||
|
if data, err := os.ReadFile("docs/" + name); err == nil {
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
if data, err := fs.ReadFile(docsFS, "docs/"+name); err == nil {
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"guide": readDoc("GUIDE.md"),
|
||||||
|
"api": readDoc("API.md"),
|
||||||
|
"updatedAt": time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,6 +209,13 @@ func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, j
|
|||||||
"runId": run.RunID, "pipeline": run.PipelineName, "durationMs": time.Since(start).Milliseconds(),
|
"runId": run.RunID, "pipeline": run.PipelineName, "durationMs": time.Since(start).Milliseconds(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 发布结束后回查最新构建信息再推飞书卡片(含 buildNumber/externalURL)
|
||||||
|
var fresh model.PipelineRun
|
||||||
|
database.DB.First(&fresh, run.ID)
|
||||||
|
if status == "failed" {
|
||||||
|
go analyzeFailure(fresh)
|
||||||
|
}
|
||||||
|
go notifyLarkRelease(fresh, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit("$ jenkins build \"" + jobName + "\"")
|
emit("$ jenkins build \"" + jobName + "\"")
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/lark"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notifyLarkRelease 运行结束后推送飞书发布结果卡片:
|
||||||
|
// Webhook 优先取流水线自带 notifyWebhookURL,否则用全局设置(需启用)。
|
||||||
|
func notifyLarkRelease(run model.PipelineRun, status string) {
|
||||||
|
if status != "success" && status != "failed" && status != "cancelled" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p model.Pipeline
|
||||||
|
database.DB.First(&p, run.PipelineID)
|
||||||
|
|
||||||
|
var s model.SystemSetting
|
||||||
|
database.DB.First(&s, 1)
|
||||||
|
|
||||||
|
webhook := p.NotifyWebhook
|
||||||
|
if webhook == "" {
|
||||||
|
if !s.LarkEnabled || s.LarkWebhook == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webhook = s.LarkWebhook
|
||||||
|
}
|
||||||
|
secret := ""
|
||||||
|
if s.LarkSecret != "" && crypto.IsEncrypted(s.LarkSecret) {
|
||||||
|
if plain, err := crypto.DecryptField(s.LarkSecret); err == nil {
|
||||||
|
secret = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build := run.RunID
|
||||||
|
if run.BuildNumber > 0 {
|
||||||
|
build = "#" + strconv.Itoa(run.BuildNumber)
|
||||||
|
}
|
||||||
|
card := lark.BuildReleaseCard(lark.ReleaseCardParams{
|
||||||
|
AppName: run.PipelineName,
|
||||||
|
Branch: run.Branch,
|
||||||
|
Build: build,
|
||||||
|
Status: status,
|
||||||
|
Time: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
BuildURL: run.ExternalURL,
|
||||||
|
})
|
||||||
|
if err := lark.Send(webhook, secret, card); err != nil {
|
||||||
|
log.Printf("[lark] 发布通知发送失败 (%s): %v", run.RunID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[lark] 发布通知已发送: %s %s → %s", run.PipelineName, status, webhook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLark POST /api/settings/test-lark —— 用已保存配置发测试卡片
|
||||||
|
func TestLark(c *gin.Context) {
|
||||||
|
var s model.SystemSetting
|
||||||
|
if err := database.DB.First(&s, 1).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请先保存设置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webhook := c.Query("webhook")
|
||||||
|
if webhook == "" {
|
||||||
|
webhook = s.LarkWebhook
|
||||||
|
}
|
||||||
|
if webhook == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "未配置飞书 Webhook"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret := ""
|
||||||
|
if s.LarkSecret != "" && crypto.IsEncrypted(s.LarkSecret) {
|
||||||
|
if plain, err := crypto.DecryptField(s.LarkSecret); err == nil {
|
||||||
|
secret = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
card := lark.BuildReleaseCard(lark.ReleaseCardParams{
|
||||||
|
AppName: "flowstack-test",
|
||||||
|
Branch: "test",
|
||||||
|
Build: "#0",
|
||||||
|
Status: "success",
|
||||||
|
Time: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
BuildURL: "",
|
||||||
|
})
|
||||||
|
if err := lark.Send(webhook, secret, card); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "发送失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试卡片已发送到群"})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,10 +14,17 @@ import (
|
|||||||
"flowstack/internal/model"
|
"flowstack/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息
|
// ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息与当前用户权限等级
|
||||||
func ListPipelines(c *gin.Context) {
|
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
|
var list []model.Pipeline
|
||||||
database.DB.Order("updated_at DESC").Find(&list)
|
q.Find(&list)
|
||||||
|
|
||||||
// 每流水线最近一次 run(一次查询取全表后内存分组,数据量小;jianmu 用反规范化表,此处无需)
|
// 每流水线最近一次 run(一次查询取全表后内存分组,数据量小;jianmu 用反规范化表,此处无需)
|
||||||
var runs []model.PipelineRun
|
var runs []model.PipelineRun
|
||||||
@@ -31,10 +39,11 @@ func ListPipelines(c *gin.Context) {
|
|||||||
type pipelineWithLastRun struct {
|
type pipelineWithLastRun struct {
|
||||||
model.Pipeline
|
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))
|
result := make([]pipelineWithLastRun, 0, len(list))
|
||||||
for _, p := range 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 {
|
if r, ok := lastByPipeline[p.ID]; ok {
|
||||||
item.LastRun = &r
|
item.LastRun = &r
|
||||||
}
|
}
|
||||||
@@ -56,8 +65,10 @@ func GetPipelineDetail(c *gin.Context) {
|
|||||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
||||||
var lastRun model.PipelineRun
|
var lastRun model.PipelineRun
|
||||||
hasRun := database.DB.Where("pipeline_id = ?", p.ID).Order("started_at DESC").First(&lastRun).Error == nil
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"pipeline": p, "nodes": nodes, "edges": edges,
|
"pipeline": p, "nodes": nodes, "edges": edges,
|
||||||
|
"accessLevel": pipelineAccessLevel(username, isAdmin, &p),
|
||||||
"lastRun": func() *model.PipelineRun {
|
"lastRun": func() *model.PipelineRun {
|
||||||
if hasRun {
|
if hasRun {
|
||||||
return &lastRun
|
return &lastRun
|
||||||
@@ -90,6 +101,8 @@ func GetSettings(c *gin.Context) {
|
|||||||
database.DB.Create(&s)
|
database.DB.Create(&s)
|
||||||
}
|
}
|
||||||
s.LlmApiKeySet = s.LlmApiKey != ""
|
s.LlmApiKeySet = s.LlmApiKey != ""
|
||||||
|
s.LarkSecretSet = s.LarkSecret != ""
|
||||||
|
s.LdapBindPasswordSet = s.LdapBindPassword != ""
|
||||||
c.JSON(http.StatusOK, s)
|
c.JSON(http.StatusOK, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,30 +152,68 @@ func UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
updates["llm_api_key"] = enc
|
updates["llm_api_key"] = enc
|
||||||
}
|
}
|
||||||
|
if v, ok := body["larkEnabled"].(bool); ok {
|
||||||
|
updates["lark_enabled"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["larkWebhook"].(string); ok {
|
||||||
|
updates["lark_webhook"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["larkSecret"].(string); ok && v != "" {
|
||||||
|
enc, err := crypto.EncryptField(v)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "签名密钥加密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["lark_secret"] = enc
|
||||||
|
}
|
||||||
database.DB.Model(&s).Updates(updates)
|
database.DB.Model(&s).Updates(updates)
|
||||||
database.DB.First(&s, 1)
|
database.DB.First(&s, 1)
|
||||||
s.LlmApiKeySet = s.LlmApiKey != ""
|
s.LlmApiKeySet = s.LlmApiKey != ""
|
||||||
|
s.LarkSecretSet = s.LarkSecret != ""
|
||||||
|
s.LdapBindPasswordSet = s.LdapBindPassword != ""
|
||||||
c.JSON(http.StatusOK, s)
|
c.JSON(http.StatusOK, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLLM POST /api/settings/test-llm —— 用已保存配置做 ping 对话
|
// TestLLM POST /api/settings/test-llm —— 支持草稿直测:body 带表单值时优先(apiKey 空=用已存)
|
||||||
func TestLLM(c *gin.Context) {
|
func TestLLM(c *gin.Context) {
|
||||||
var s model.SystemSetting
|
var s model.SystemSetting
|
||||||
if err := database.DB.First(&s, 1).Error; err != nil {
|
if err := database.DB.First(&s, 1).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请先保存设置"})
|
s = model.SystemSetting{}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if !s.LlmEnabled || s.LlmApiKey == "" {
|
var body struct {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "AI 未启用或未配置 API Key"})
|
LlmEnabled *bool `json:"llmEnabled"`
|
||||||
return
|
LlmEndpoint string `json:"llmEndpoint"`
|
||||||
|
LlmModel string `json:"llmModel"`
|
||||||
|
LlmApiKey string `json:"llmApiKey"`
|
||||||
}
|
}
|
||||||
apiKey := s.LlmApiKey
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
|
||||||
|
enabled := s.LlmEnabled
|
||||||
|
if body.LlmEnabled != nil {
|
||||||
|
enabled = *body.LlmEnabled
|
||||||
|
}
|
||||||
|
endpoint := body.LlmEndpoint
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = s.LlmEndpoint
|
||||||
|
}
|
||||||
|
model_ := body.LlmModel
|
||||||
|
if model_ == "" {
|
||||||
|
model_ = s.LlmModel
|
||||||
|
}
|
||||||
|
apiKey := body.LlmApiKey
|
||||||
|
if apiKey == "" || strings.Contains(apiKey, "****") {
|
||||||
|
apiKey = s.LlmApiKey
|
||||||
if crypto.IsEncrypted(apiKey) {
|
if crypto.IsEncrypted(apiKey) {
|
||||||
if plain, err := crypto.DecryptField(apiKey); err == nil {
|
if plain, err := crypto.DecryptField(apiKey); err == nil {
|
||||||
apiKey = plain
|
apiKey = plain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
client := llm.NewClient(s.LlmEndpoint, apiKey, s.LlmModel, 128)
|
}
|
||||||
|
if !enabled || apiKey == "" || endpoint == "" || model_ == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写启用/接口/模型/API Key(或先保存已有配置)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := llm.NewClient(endpoint, apiKey, model_, 128)
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
reply, err := client.Chat(ctx, "你是连通性检查助手,只回复:连接成功。", "ping")
|
reply, err := client.Chat(ctx, "你是连通性检查助手,只回复:连接成功。", "ping")
|
||||||
|
|||||||
@@ -305,6 +305,12 @@ func summarizeCredential(typ, contentJSON string) string {
|
|||||||
return typ + " " + pick("server") + " " + pick("username")
|
return typ + " " + pick("server") + " " + pick("username")
|
||||||
case "git":
|
case "git":
|
||||||
return typ + " " + pick("url", "username")
|
return typ + " " + pick("url", "username")
|
||||||
|
case "kubernetes":
|
||||||
|
mode, _ := m["mode"].(string)
|
||||||
|
if mode == "kubeconfig" {
|
||||||
|
return "K8s Kubeconfig"
|
||||||
|
}
|
||||||
|
return "K8s Token " + pick("namespace") + "@" + pick("apiServer")
|
||||||
case "kubeconfig":
|
case "kubeconfig":
|
||||||
return typ + " (kubeconfig)"
|
return typ + " (kubeconfig)"
|
||||||
case "aksk":
|
case "aksk":
|
||||||
@@ -394,7 +400,7 @@ func UpdateBuildNode(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var raw map[string]any
|
var raw map[string]any
|
||||||
_ = c.ShouldBindJSON(&raw)
|
_ = c.ShouldBindJSON(&raw)
|
||||||
for _, k := range []string{"name", "ip", "labels", "maxConcurrency", "workspacePath", "enabled"} {
|
for _, k := range []string{"name", "ip", "labels", "maxConcurrency", "workspacePath", "diskInfo", "credentialId", "enabled"} {
|
||||||
if v, ok := raw[k]; ok {
|
if v, ok := raw[k]; ok {
|
||||||
database.DB.Model(&n).Update(k, v)
|
database.DB.Model(&n).Update(k, v)
|
||||||
}
|
}
|
||||||
@@ -448,7 +454,7 @@ func UpdateTool(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var raw map[string]any
|
var raw map[string]any
|
||||||
_ = c.ShouldBindJSON(&raw)
|
_ = c.ShouldBindJSON(&raw)
|
||||||
for _, k := range []string{"installMode", "version", "path", "enabled"} {
|
for _, k := range []string{"installMode", "version", "path", "installHint", "enabled"} {
|
||||||
if v, ok := raw[k]; ok {
|
if v, ok := raw[k]; ok {
|
||||||
database.DB.Model(&t).Update(k, v)
|
database.DB.Model(&t).Update(k, v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ func CreateRun(hub *Hub) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
_ = c.ShouldBindJSON(&body)
|
_ = 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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -127,13 +132,17 @@ func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
|
|||||||
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
|
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
|
||||||
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
|
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
|
||||||
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
|
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
|
||||||
// 通知订阅了 pipeline.success / pipeline.failed 的出站 Webhook
|
// 通知订阅了 pipeline.success / pipeline.failed 的出站 Webhook + 飞书发布卡片
|
||||||
if status == "success" || status == "failed" {
|
if status == "success" || status == "failed" {
|
||||||
NotifyWebhookEvent("pipeline."+status, map[string]any{
|
NotifyWebhookEvent("pipeline."+status, map[string]any{
|
||||||
"runId": run.RunID, "pipeline": run.PipelineName,
|
"runId": run.RunID, "pipeline": run.PipelineName,
|
||||||
"branch": run.Branch, "durationMs": time.Since(start).Milliseconds(),
|
"branch": run.Branch, "durationMs": time.Since(start).Milliseconds(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if status == "failed" {
|
||||||
|
go analyzeFailure(run)
|
||||||
|
}
|
||||||
|
go notifyLarkRelease(run, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
|
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
|
||||||
@@ -218,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) {
|
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)
|
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 != "" {
|
if v := c.Query("pipelineId"); v != "" {
|
||||||
q = q.Where("pipeline_id = ?", v)
|
q = q.Where("pipeline_id = ?", v)
|
||||||
}
|
}
|
||||||
@@ -229,7 +244,25 @@ func ListRuns(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var runs []model.PipelineRun
|
var runs []model.PipelineRun
|
||||||
q.Find(&runs)
|
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
|
// GetRun GET /api/runs/:runId
|
||||||
@@ -312,7 +345,7 @@ func RetryRun(hub *Hub) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/ldap"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- LDAP 配置辅助 ----
|
||||||
|
|
||||||
|
type ldapRuntime struct {
|
||||||
|
Host, BindDN, BindPassword, BaseDN, UserFilter, UsernameAttr, NameAttr, EmailAttr string
|
||||||
|
Port int
|
||||||
|
UseSSL bool
|
||||||
|
Enabled, Configured bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadLDAP() ldapRuntime {
|
||||||
|
var s model.SystemSetting
|
||||||
|
database.DB.First(&s, 1)
|
||||||
|
rt := ldapRuntime{
|
||||||
|
Host: s.LdapHost, Port: s.LdapPort, UseSSL: s.LdapUseSSL,
|
||||||
|
BindDN: s.LdapBindDN, BaseDN: s.LdapBaseDN,
|
||||||
|
UserFilter: s.LdapUserFilter, UsernameAttr: s.LdapUsernameAttr,
|
||||||
|
NameAttr: s.LdapNameAttr, EmailAttr: s.LdapEmailAttr,
|
||||||
|
Enabled: s.LdapEnabled,
|
||||||
|
}
|
||||||
|
rt.Configured = rt.Host != "" && rt.BindDN != "" && rt.BaseDN != ""
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt ldapRuntime) bindPassword() string {
|
||||||
|
var s model.SystemSetting
|
||||||
|
database.DB.First(&s, 1)
|
||||||
|
if s.LdapBindPassword == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if crypto.IsEncrypted(s.LdapBindPassword) {
|
||||||
|
if plain, err := crypto.DecryptField(s.LdapBindPassword); err == nil {
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.LdapBindPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
var errLDAPDisabled = errors.New("ldap disabled")
|
||||||
|
|
||||||
|
// ldapVerifyUser 登录用:目录搜索 + 用户 DN 直连 bind
|
||||||
|
func ldapVerifyUser(username, password string) (*ldap.Entry, error) {
|
||||||
|
rt := loadLDAP()
|
||||||
|
if !rt.Enabled || !rt.Configured {
|
||||||
|
return nil, errLDAPDisabled
|
||||||
|
}
|
||||||
|
entry, err := ldap.SearchOne(rt.Host, rt.Port, rt.UseSSL, rt.BindDN, rt.bindPassword(), rt.BaseDN, rt.UserFilter, rt.UsernameAttr, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ldap.BindAsUser(rt.Host, rt.Port, rt.UseSSL, entry.DN, password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncLDAPUser LDAP 验证通过后同步/建号(JIT)
|
||||||
|
func syncLDAPUser(entry *ldap.Entry) model.User {
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.Where("username = ?", entry.Username).First(&user).Error; err == nil {
|
||||||
|
// 已存在:本地同名转 LDAP + 同步昵称邮箱
|
||||||
|
database.DB.Model(&user).Updates(map[string]any{
|
||||||
|
"auth_source": "ldap", "nickname": entry.DisplayName, "email": entry.Email,
|
||||||
|
})
|
||||||
|
database.DB.First(&user, user.ID)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
user = model.User{
|
||||||
|
Username: entry.Username, Nickname: entry.DisplayName, Email: entry.Email,
|
||||||
|
Role: "user", AuthSource: "ldap",
|
||||||
|
// 占位密码:本地不可登录
|
||||||
|
Password: mustHash("ldap-user-" + entry.Username + "-no-local-login"),
|
||||||
|
}
|
||||||
|
database.DB.Create(&user)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustHash(s string) string {
|
||||||
|
h, _ := crypto.HashPassword(s)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 用户管理 ----
|
||||||
|
|
||||||
|
// ListUsers GET /api/users
|
||||||
|
func ListUsers(c *gin.Context) {
|
||||||
|
var list []model.User
|
||||||
|
database.DB.Order("created_at ASC").Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser POST /api/users —— 创建本地用户
|
||||||
|
func CreateUser(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Role != "admin" {
|
||||||
|
body.Role = "user"
|
||||||
|
}
|
||||||
|
password := body.Password
|
||||||
|
if password == "" {
|
||||||
|
var err error
|
||||||
|
password, err = crypto.RandomPassword(16)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hash, err := crypto.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
Username: body.Username, Nickname: body.Nickname, Email: body.Email,
|
||||||
|
Role: body.Role, AuthSource: "local", Password: hash,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&user).Error; err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:用户名已存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 密码仅在创建时回显一次(指定密码时不回显明文,回显随机生成的)
|
||||||
|
result := gin.H{"id": user.ID, "username": user.Username, "nickname": user.Nickname, "email": user.Email, "role": user.Role, "authSource": user.AuthSource}
|
||||||
|
if body.Password == "" {
|
||||||
|
result["initialPassword"] = password
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword POST /api/users/:id/reset-password —— LDAP 用户拒绝
|
||||||
|
func ResetPassword(c *gin.Context) {
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.AuthSource == "ldap" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "LDAP 用户密码由目录服务管理"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password, err := crypto.RandomPassword(16)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := crypto.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
database.DB.Model(&user).Update("password", hash)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "initialPassword": password})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser DELETE /api/users/:id —— 不可删最后一个管理员/自己
|
||||||
|
func DeleteUser(c *gin.Context) {
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Username == c.GetString("username") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除当前登录用户"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Role == "admin" {
|
||||||
|
var adminCount int64
|
||||||
|
database.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount)
|
||||||
|
if adminCount <= 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除最后一个管理员"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
database.DB.Delete(&user)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser PUT /api/users/:id —— 角色/昵称/邮箱
|
||||||
|
func UpdateUser(c *gin.Context) {
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
if v, ok := body["role"].(string); ok && (v == "admin" || v == "user") {
|
||||||
|
database.DB.Model(&user).Update("role", v)
|
||||||
|
}
|
||||||
|
if v, ok := body["nickname"].(string); ok {
|
||||||
|
database.DB.Model(&user).Update("nickname", v)
|
||||||
|
}
|
||||||
|
if v, ok := body["email"].(string); ok {
|
||||||
|
database.DB.Model(&user).Update("email", v)
|
||||||
|
}
|
||||||
|
database.DB.First(&user, user.ID)
|
||||||
|
c.JSON(http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- LDAP 用户导入(LokiStack 交互与冲突策略) ----
|
||||||
|
|
||||||
|
// LdapListUsers POST /api/ldap/users —— 导入预览(未启用返回 400)
|
||||||
|
func LdapListUsers(c *gin.Context) {
|
||||||
|
rt := loadLDAP()
|
||||||
|
if !rt.Enabled || !rt.Configured {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "LDAP 未启用或未配置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := ldap.SearchUsers(rt.Host, rt.Port, rt.UseSSL, rt.BindDN, rt.bindPassword(),
|
||||||
|
rt.BaseDN, rt.UserFilter, rt.UsernameAttr, rt.NameAttr, rt.EmailAttr, 500)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LdapImport POST /api/users/ldap-import —— 批量导入(跳过已LDAP/本地同名转LDAP/新建)
|
||||||
|
func LdapImport(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Users []struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"users" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Users) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要导入的用户"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var created, converted, skipped, failed int
|
||||||
|
var failedUsers []string
|
||||||
|
for _, in := range body.Users {
|
||||||
|
if in.Username == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var exist model.User
|
||||||
|
if err := database.DB.Where("username = ?", in.Username).First(&exist).Error; err == nil {
|
||||||
|
if exist.AuthSource == "ldap" {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 本地同名 → 转为 LDAP
|
||||||
|
database.DB.Model(&exist).Updates(map[string]any{"auth_source": "ldap", "nickname": in.Nickname, "email": in.Email})
|
||||||
|
converted++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
Username: in.Username, Nickname: in.Nickname, Email: in.Email,
|
||||||
|
Role: "user", AuthSource: "ldap",
|
||||||
|
Password: mustHash("ldap-user-" + in.Username + "-no-local-login"),
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&user).Error; err != nil {
|
||||||
|
failed++
|
||||||
|
failedUsers = append(failedUsers, in.Username)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"created": created, "converted": converted, "skipped": skipped,
|
||||||
|
"failed": failed, "failedUsers": failedUsers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLDAP POST /api/settings/ldap/test —— 支持草稿直测 + 可选测试账号
|
||||||
|
func TestLDAP(c *gin.Context) {
|
||||||
|
var s model.SystemSetting
|
||||||
|
database.DB.First(&s, 1)
|
||||||
|
var body struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port float64 `json:"port"`
|
||||||
|
UseSSL *bool `json:"useSSL"`
|
||||||
|
BindDN string `json:"bindDN"`
|
||||||
|
BindPassword string `json:"bindPassword"`
|
||||||
|
BaseDN string `json:"baseDN"`
|
||||||
|
UserFilter string `json:"userFilter"`
|
||||||
|
UsernameAttr string `json:"usernameAttr"`
|
||||||
|
TestUsername string `json:"testUsername"`
|
||||||
|
TestPassword string `json:"testPassword"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
|
||||||
|
host := body.Host
|
||||||
|
if host == "" {
|
||||||
|
host = s.LdapHost
|
||||||
|
}
|
||||||
|
bindDN := body.BindDN
|
||||||
|
if bindDN == "" {
|
||||||
|
bindDN = s.LdapBindDN
|
||||||
|
}
|
||||||
|
password := body.BindPassword
|
||||||
|
if password == "" {
|
||||||
|
password = ""
|
||||||
|
var stored string
|
||||||
|
database.DB.Select("ldap_bind_password").First(&s, 1)
|
||||||
|
stored = s.LdapBindPassword
|
||||||
|
if crypto.IsEncrypted(stored) {
|
||||||
|
if plain, err := crypto.DecryptField(stored); err == nil {
|
||||||
|
password = plain
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
password = stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseDN := body.BaseDN
|
||||||
|
if baseDN == "" {
|
||||||
|
baseDN = s.LdapBaseDN
|
||||||
|
}
|
||||||
|
port := int(body.Port)
|
||||||
|
if port == 0 {
|
||||||
|
port = s.LdapPort
|
||||||
|
}
|
||||||
|
if port == 0 {
|
||||||
|
port = 389
|
||||||
|
}
|
||||||
|
useSSL := s.LdapUseSSL
|
||||||
|
if body.UseSSL != nil {
|
||||||
|
useSSL = *body.UseSSL
|
||||||
|
}
|
||||||
|
usernameAttr := body.UsernameAttr
|
||||||
|
if usernameAttr == "" {
|
||||||
|
usernameAttr = s.LdapUsernameAttr
|
||||||
|
}
|
||||||
|
userFilter := body.UserFilter
|
||||||
|
if userFilter == "" {
|
||||||
|
userFilter = s.LdapUserFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == "" || bindDN == "" || baseDN == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请填写服务器/Bind DN/Base DN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := ldap.Dial(host, port, useSSL, bindDN, password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// 可选:测试账号级验证
|
||||||
|
if body.TestUsername != "" && body.TestPassword != "" {
|
||||||
|
entry, err := ldap.SearchOne(host, port, useSSL, bindDN, password, baseDN, userFilter, usernameAttr, body.TestUsername)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试账号搜索失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ldap.BindAsUser(host, port, useSSL, entry.DN, body.TestPassword); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试账号密码验证失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "连接与测试账号验证成功:" + entry.DisplayName + " (" + entry.Email + ")"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "LDAP 连接成功(管理员绑定通过)"})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Package lark 飞书/Lark 群自定义机器人卡片通知。
|
||||||
|
package lark
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReleaseCardParams 发布结果卡片字段
|
||||||
|
type ReleaseCardParams struct {
|
||||||
|
AppName string // 应用名
|
||||||
|
Branch string // 分支
|
||||||
|
Build string // 构建号(如 #172 或 pr-xxx)
|
||||||
|
Status string // success | failed | 其他
|
||||||
|
Time string // 展示时间
|
||||||
|
BuildURL string // 「查看构建详情」按钮地址(空则不放按钮)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusMeta(status string) (template, text string) {
|
||||||
|
switch status {
|
||||||
|
case "success":
|
||||||
|
return "green", "✅ 成功"
|
||||||
|
case "failed":
|
||||||
|
return "red", "❌ 失败"
|
||||||
|
case "cancelled":
|
||||||
|
return "yellow", "⚠️ 已取消"
|
||||||
|
default:
|
||||||
|
return "yellow", "⚠️ " + status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildReleaseCard 组装发布结果卡片(结构对齐现有 Jenkins 通知脚本)
|
||||||
|
func BuildReleaseCard(p ReleaseCardParams) map[string]any {
|
||||||
|
_, statusText := statusMeta(p.Status)
|
||||||
|
template, _ := statusMeta(p.Status)
|
||||||
|
|
||||||
|
elements := []any{
|
||||||
|
map[string]any{
|
||||||
|
"tag": "div",
|
||||||
|
"text": map[string]any{
|
||||||
|
"tag": "lark_md",
|
||||||
|
"content": fmt.Sprintf(
|
||||||
|
"**应用:** %s\n**分支:** %s\n**构建:** %s\n**状态:** %s\n**时间:** %s",
|
||||||
|
p.AppName, p.Branch, p.Build, statusText, p.Time,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if p.BuildURL != "" {
|
||||||
|
elements = append(elements, map[string]any{
|
||||||
|
"tag": "action",
|
||||||
|
"actions": []any{map[string]any{
|
||||||
|
"tag": "button",
|
||||||
|
"text": map[string]any{"tag": "plain_text", "content": "查看构建详情"},
|
||||||
|
"url": p.BuildURL,
|
||||||
|
"type": "default",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, statusOnly := statusMeta(p.Status)
|
||||||
|
title := "发布结果通知 - " + statusOnly
|
||||||
|
return map[string]any{
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"card": map[string]any{
|
||||||
|
"config": map[string]any{"wide_screen_mode": true},
|
||||||
|
"header": map[string]any{
|
||||||
|
"template": template,
|
||||||
|
"title": map[string]any{"tag": "plain_text", "content": title},
|
||||||
|
},
|
||||||
|
"elements": elements,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send 发送卡片;secret 非空时按飞书自定义机器人规范加签(timestamp+sign)
|
||||||
|
func Send(webhook, secret string, card map[string]any) error {
|
||||||
|
if webhook == "" {
|
||||||
|
return fmt.Errorf("webhook 未配置")
|
||||||
|
}
|
||||||
|
if secret != "" {
|
||||||
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
mac := hmac.New(sha256.New, []byte(ts+"\n"+secret))
|
||||||
|
mac.Write(nil)
|
||||||
|
card["timestamp"] = ts
|
||||||
|
card["sign"] = base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(card)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Post(webhook, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
// 飞书成功返回 {"code":0,...} / {"StatusCode":0}
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
StatusCode int `json:"StatusCode"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
ErrorMessage string `json:"ErrorMessage"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(data, &out)
|
||||||
|
if out.Code != 0 || out.StatusCode != 0 {
|
||||||
|
msg := out.Msg
|
||||||
|
if msg == "" {
|
||||||
|
msg = out.ErrorMessage
|
||||||
|
}
|
||||||
|
return fmt.Errorf("飞书返回错误: %s", msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Package ldap 目录服务集成(go-ldap,实现模式参考 LokiStack)。
|
||||||
|
package ldap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry LDAP 用户条目
|
||||||
|
type Entry struct {
|
||||||
|
DN string `json:"dn"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial 连接并完成管理员 Bind(5s 拨号 / 10s 操作超时)
|
||||||
|
func Dial(host string, port int, useSSL bool, bindDN, bindPassword string) (*ldap.Conn, error) {
|
||||||
|
scheme := "ldap"
|
||||||
|
if useSSL {
|
||||||
|
scheme = "ldaps"
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||||
|
conn, err := ldap.DialURL(url,
|
||||||
|
ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}),
|
||||||
|
ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("无法连接 LDAP 服务器 %s: %v", url, err)
|
||||||
|
}
|
||||||
|
conn.SetTimeout(10 * time.Second)
|
||||||
|
if err := conn.Bind(bindDN, bindPassword); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("管理员绑定失败(检查 Bind DN/密码): %v", err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFilter(f, def string) string {
|
||||||
|
if strings.TrimSpace(f) == "" {
|
||||||
|
f = def
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(f, "(") {
|
||||||
|
f = "(" + f + ")"
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUsers 按 BaseDN/过滤器搜索用户(sizeLimit 截断)
|
||||||
|
func SearchUsers(host string, port int, useSSL bool, bindDN, bindPassword, baseDN, userFilter, usernameAttr, nameAttr, emailAttr string, sizeLimit int) ([]Entry, error) {
|
||||||
|
conn, err := Dial(host, port, useSSL, bindDN, bindPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if usernameAttr == "" {
|
||||||
|
usernameAttr = "uid"
|
||||||
|
}
|
||||||
|
if nameAttr == "" {
|
||||||
|
nameAttr = "displayName"
|
||||||
|
}
|
||||||
|
if emailAttr == "" {
|
||||||
|
emailAttr = "mail"
|
||||||
|
}
|
||||||
|
req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||||
|
sizeLimit, 0, false, normalizeFilter(userFilter, "(objectClass=inetOrgPerson)"),
|
||||||
|
[]string{"dn", usernameAttr, nameAttr, "cn", emailAttr}, nil)
|
||||||
|
res, err := conn.Search(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("搜索失败: %v", err)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []Entry
|
||||||
|
for _, e := range res.Entries {
|
||||||
|
username := e.GetAttributeValue(usernameAttr)
|
||||||
|
if username == "" {
|
||||||
|
continue // 跳过 OU 等非用户条目
|
||||||
|
}
|
||||||
|
if seen[e.DN] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[e.DN] = true
|
||||||
|
name := e.GetAttributeValue(nameAttr)
|
||||||
|
if name == "" {
|
||||||
|
name = e.GetAttributeValue("cn")
|
||||||
|
}
|
||||||
|
out = append(out, Entry{
|
||||||
|
DN: e.DN, Username: username, DisplayName: name, Email: e.GetAttributeValue(emailAttr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchOne 按用户名搜索单个条目(登录用,EscapeFilter 防注入)
|
||||||
|
func SearchOne(host string, port int, useSSL bool, bindDN, bindPassword, baseDN, userFilter, usernameAttr, username string) (*Entry, error) {
|
||||||
|
conn, err := Dial(host, port, useSSL, bindDN, bindPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if usernameAttr == "" {
|
||||||
|
usernameAttr = "uid"
|
||||||
|
}
|
||||||
|
filter := fmt.Sprintf("(&%s(%s=%s))", normalizeFilter(userFilter, "(objectClass=inetOrgPerson)"),
|
||||||
|
usernameAttr, ldap.EscapeFilter(username))
|
||||||
|
req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||||
|
2, 0, false, filter, []string{"dn", usernameAttr, "displayName", "cn", "mail"}, nil)
|
||||||
|
res, err := conn.Search(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(res.Entries) == 0 {
|
||||||
|
return nil, fmt.Errorf("用户不存在")
|
||||||
|
}
|
||||||
|
if len(res.Entries) > 1 {
|
||||||
|
return nil, fmt.Errorf("LDAP 中存在多个匹配的用户")
|
||||||
|
}
|
||||||
|
e := res.Entries[0]
|
||||||
|
name := e.GetAttributeValue("displayName")
|
||||||
|
if name == "" {
|
||||||
|
name = e.GetAttributeValue("cn")
|
||||||
|
}
|
||||||
|
return &Entry{DN: e.DN, Username: e.GetAttributeValue(usernameAttr), DisplayName: name, Email: e.GetAttributeValue("mail")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindAsUser 用用户自己的 DN+密码直连校验(密码不落库)
|
||||||
|
func BindAsUser(host string, port int, useSSL bool, userDN, password string) error {
|
||||||
|
scheme := "ldap"
|
||||||
|
if useSSL {
|
||||||
|
scheme = "ldaps"
|
||||||
|
}
|
||||||
|
conn, err := ldap.DialURL(fmt.Sprintf("%s://%s:%d", scheme, host, port),
|
||||||
|
ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}),
|
||||||
|
ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
return conn.Bind(userDN, password)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
package llm
|
package llm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -41,6 +42,7 @@ type chatRequest struct {
|
|||||||
Messages []chatMessage `json:"messages"`
|
Messages []chatMessage `json:"messages"`
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
MaxTokens int `json:"max_tokens"`
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type chatResponse struct {
|
type chatResponse struct {
|
||||||
@@ -111,3 +113,108 @@ func truncate(s string, n int) string {
|
|||||||
}
|
}
|
||||||
return s[:n] + "..."
|
return s[:n] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StreamDelta 单个流式增量
|
||||||
|
type StreamDelta struct {
|
||||||
|
Content string // 正文增量
|
||||||
|
Reasoning string // 推理过程增量(DeepSeek R1 类模型)
|
||||||
|
Usage *Usage // 最后一个 chunk 可能带用量
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage token 用量
|
||||||
|
type Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamChunk struct {
|
||||||
|
Choices []struct {
|
||||||
|
Delta struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Reasoning string `json:"reasoning_content"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"delta"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage *Usage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatStream 流式对话:onDelta 逐段回调(正文/推理),返回完整正文。
|
||||||
|
// 流式请求不设整体超时,由 ctx 控制取消。
|
||||||
|
func (c *Client) ChatStream(ctx context.Context, system, user string, onDelta func(StreamDelta)) (string, error) {
|
||||||
|
if c.Endpoint == "" || c.APIKey == "" || c.Model == "" {
|
||||||
|
return "", fmt.Errorf("LLM 未配置")
|
||||||
|
}
|
||||||
|
payload := chatRequest{
|
||||||
|
Model: c.Model,
|
||||||
|
Messages: []chatMessage{
|
||||||
|
{Role: "system", Content: system},
|
||||||
|
{Role: "user", Content: user},
|
||||||
|
},
|
||||||
|
Temperature: 0.2,
|
||||||
|
MaxTokens: c.MaxTokens,
|
||||||
|
Stream: true,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint+"/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
|
||||||
|
// 流式专用 client:Timeout 0,取消由 ctx 控制
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(data), 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
var full strings.Builder
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if !strings.HasPrefix(line, "data:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||||
|
if data == "" || data == "[DONE]" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var chunk streamChunk
|
||||||
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(chunk.Choices) == 0 {
|
||||||
|
if chunk.Usage != nil && onDelta != nil {
|
||||||
|
onDelta(StreamDelta{Usage: chunk.Usage})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := chunk.Choices[0].Delta
|
||||||
|
if d.Content != "" || d.Reasoning != "" {
|
||||||
|
full.WriteString(d.Content)
|
||||||
|
if onDelta != nil {
|
||||||
|
onDelta(StreamDelta{Content: d.Content, Reasoning: d.Reasoning})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return full.String(), nil // 客户端主动取消:返回已收到的内容
|
||||||
|
}
|
||||||
|
return full.String(), err
|
||||||
|
}
|
||||||
|
if full.Len() == 0 {
|
||||||
|
return "", fmt.Errorf("流式响应为空")
|
||||||
|
}
|
||||||
|
return full.String(), nil
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ type User struct {
|
|||||||
Username string `gorm:"uniqueIndex" json:"username"`
|
Username string `gorm:"uniqueIndex" json:"username"`
|
||||||
Password string `json:"-"` // argon2id 哈希,绝不返回给前端
|
Password string `json:"-"` // argon2id 哈希,绝不返回给前端
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
AuthSource string `gorm:"default:local" json:"authSource"` // local | ldap
|
||||||
Role string `gorm:"default:user" json:"role"` // admin | user
|
Role string `gorm:"default:user" json:"role"` // admin | user
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
@@ -38,6 +40,30 @@ type Pipeline struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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 对齐)
|
// PipelineNode 画布节点(JSON 字段名与前端 React Flow 对齐)
|
||||||
type PipelineNode struct {
|
type PipelineNode struct {
|
||||||
ID uint `gorm:"primaryKey" json:"dbId"`
|
ID uint `gorm:"primaryKey" json:"dbId"`
|
||||||
@@ -79,6 +105,7 @@ type PipelineRun struct {
|
|||||||
Status string `gorm:"default:pending" json:"status"` // pending|queued|running|success|failed|cancelled
|
Status string `gorm:"default:pending" json:"status"` // pending|queued|running|success|failed|cancelled
|
||||||
DurationMs int64 `json:"durationMs"`
|
DurationMs int64 `json:"durationMs"`
|
||||||
BuildNumber int `json:"buildNumber"`
|
BuildNumber int `json:"buildNumber"`
|
||||||
|
Analysis string `gorm:"type:text" json:"analysis"` // 发布失败时的 AI 分析
|
||||||
ExternalRunID string `json:"externalRunId"`
|
ExternalRunID string `json:"externalRunId"`
|
||||||
ExternalURL string `json:"externalUrl"`
|
ExternalURL string `json:"externalUrl"`
|
||||||
NodesSnapshot string `json:"nodesSnapshot"`
|
NodesSnapshot string `json:"nodesSnapshot"`
|
||||||
@@ -204,6 +231,12 @@ type BuildNode struct {
|
|||||||
Labels string `gorm:"type:text" json:"labels"` // JSON ["docker","go"]
|
Labels string `gorm:"type:text" json:"labels"` // JSON ["docker","go"]
|
||||||
MaxConcurrency int `gorm:"default:2" json:"maxConcurrency"`
|
MaxConcurrency int `gorm:"default:2" json:"maxConcurrency"`
|
||||||
WorkspacePath string `gorm:"default:/data/flowstack-workspace" json:"workspacePath"`
|
WorkspacePath string `gorm:"default:/data/flowstack-workspace" json:"workspacePath"`
|
||||||
|
DiskInfo string `json:"diskInfo"` // 如 "45G/200G"
|
||||||
|
CredentialID *uint `json:"credentialId"` // SSH 凭证(磁盘性能测试等远程操作)
|
||||||
|
DiskWriteMBps float64 `gorm:"column:disk_write_mbps" json:"diskWriteMBps"`
|
||||||
|
DiskReadMBps float64 `gorm:"column:disk_read_mbps" json:"diskReadMBps"`
|
||||||
|
DiskTestMode string `json:"diskTestMode"` // local|ssh + /direct|/cache
|
||||||
|
DiskTestedAt *time.Time `json:"diskTestedAt"`
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
Status string `gorm:"default:offline" json:"status"` // online|offline|busy
|
Status string `gorm:"default:offline" json:"status"` // online|offline|busy
|
||||||
CurrentJobs int `gorm:"default:0" json:"currentJobs"`
|
CurrentJobs int `gorm:"default:0" json:"currentJobs"`
|
||||||
@@ -224,6 +257,7 @@ type DeployTool struct {
|
|||||||
InstallMode string `gorm:"default:lazy" json:"installMode"` // preinstalled|lazy|disabled
|
InstallMode string `gorm:"default:lazy" json:"installMode"` // preinstalled|lazy|disabled
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
CheckCommand string `gorm:"type:text" json:"checkCommand"` // 真实版本探测命令
|
CheckCommand string `gorm:"type:text" json:"checkCommand"` // 真实版本探测命令
|
||||||
|
InstallHint string `gorm:"type:text" json:"installHint"` // 安装命令提示(Ubuntu apt / nvm)
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
LastCheck *time.Time `json:"lastCheck"`
|
LastCheck *time.Time `json:"lastCheck"`
|
||||||
ErrorMsg string `json:"errorMsg"`
|
ErrorMsg string `json:"errorMsg"`
|
||||||
@@ -273,6 +307,22 @@ type SystemSetting struct {
|
|||||||
LlmApiKey string `gorm:"type:text" json:"-"` // enc:v1: 加密,只写不读
|
LlmApiKey string `gorm:"type:text" json:"-"` // enc:v1: 加密,只写不读
|
||||||
LlmApiKeySet bool `gorm:"-" json:"llmApiKeySet"`
|
LlmApiKeySet bool `gorm:"-" json:"llmApiKeySet"`
|
||||||
LlmMaxTokens int `gorm:"default:1200" json:"llmMaxTokens"`
|
LlmMaxTokens int `gorm:"default:1200" json:"llmMaxTokens"`
|
||||||
|
LarkEnabled bool `gorm:"default:false" json:"larkEnabled"`
|
||||||
|
LarkWebhook string `json:"larkWebhook"` // 飞书群自定义机器人 Webhook
|
||||||
|
LarkSecret string `gorm:"type:text" json:"-"` // 加签密钥(可选),enc:v1: 加密
|
||||||
|
LarkSecretSet bool `gorm:"-" json:"larkSecretSet"`
|
||||||
|
LdapEnabled bool `json:"ldapEnabled"`
|
||||||
|
LdapHost string `json:"ldapHost"`
|
||||||
|
LdapPort int `gorm:"default:389" json:"ldapPort"`
|
||||||
|
LdapUseSSL bool `json:"ldapUseSSL"`
|
||||||
|
LdapBindDN string `json:"ldapBindDN"`
|
||||||
|
LdapBindPassword string `gorm:"type:text" json:"-"` // enc:v1: 加密只写
|
||||||
|
LdapBindPasswordSet bool `gorm:"-" json:"ldapBindPasswordSet"`
|
||||||
|
LdapBaseDN string `json:"ldapBaseDN"`
|
||||||
|
LdapUserFilter string `json:"ldapUserFilter"`
|
||||||
|
LdapUsernameAttr string `json:"ldapUsernameAttr"`
|
||||||
|
LdapNameAttr string `json:"ldapNameAttr"`
|
||||||
|
LdapEmailAttr string `json:"ldapEmailAttr"`
|
||||||
Extra string `gorm:"type:text" json:"extra"`
|
Extra string `gorm:"type:text" json:"extra"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Package sshexec 远程命令执行通道(构建节点 SSH 操作)。
|
||||||
|
package sshexec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config SSH 连接参数(来自 SSH 凭证内容 JSON)
|
||||||
|
type Config struct {
|
||||||
|
Host string // IP
|
||||||
|
Port int // 默认 22
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
PrivateKey string // PEM 私钥(与 Password 二选一)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 执行单条远程命令,返回合并输出;timeout 为整条命令的硬上限
|
||||||
|
func Run(cfg Config, cmd string, timeout time.Duration) (string, error) {
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = 22
|
||||||
|
}
|
||||||
|
if cfg.User == "" {
|
||||||
|
cfg.User = "root"
|
||||||
|
}
|
||||||
|
|
||||||
|
var authMethods []ssh.AuthMethod
|
||||||
|
if cfg.PrivateKey != "" {
|
||||||
|
signer, err := ssh.ParsePrivateKey([]byte(cfg.PrivateKey))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("私钥解析失败: %v", err)
|
||||||
|
}
|
||||||
|
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||||
|
}
|
||||||
|
if cfg.Password != "" {
|
||||||
|
authMethods = append(authMethods, ssh.Password(cfg.Password))
|
||||||
|
}
|
||||||
|
if len(authMethods) == 0 {
|
||||||
|
return "", fmt.Errorf("未提供密码或私钥")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := ssh.Dial("tcp", net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port)), &ssh.ClientConfig{
|
||||||
|
User: cfg.User,
|
||||||
|
Auth: authMethods,
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // 内网构建节点场景
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("SSH 连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
var output []byte
|
||||||
|
var runErr error
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
output, runErr = session.CombinedOutput(cmd)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return string(output), runErr
|
||||||
|
case <-time.After(timeout):
|
||||||
|
_ = session.Close()
|
||||||
|
return string(output), fmt.Errorf("命令执行超时(%s)", timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
+129
-64
@@ -19,6 +19,11 @@ import (
|
|||||||
//go:embed all:static
|
//go:embed all:static
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
// 使用指南与接口文档(运行目录 docs/ 优先)
|
||||||
|
//
|
||||||
|
//go:embed docs/GUIDE.md docs/API.md
|
||||||
|
var docsFS embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := database.Init(); err != nil {
|
if err := database.Init(); err != nil {
|
||||||
panic("数据库初始化失败: " + err.Error())
|
panic("数据库初始化失败: " + err.Error())
|
||||||
@@ -27,6 +32,9 @@ func main() {
|
|||||||
hub := handler.NewHub()
|
hub := handler.NewHub()
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
// 会话鉴权:所有 /api 需登录(豁免登录/健康检查/入站 Webhook/SSE 日志流)
|
||||||
|
r.Use(handler.AuthMiddleware())
|
||||||
|
|
||||||
// ?_clear=1 时下发 Clear-Site-Data,用于清除浏览器缓存的错误重定向等脏缓存
|
// ?_clear=1 时下发 Clear-Site-Data,用于清除浏览器缓存的错误重定向等脏缓存
|
||||||
// (历史 bug:/index.html 曾返回 301 → ./,浏览器永久缓存后反复触发 ERR_TOO_MANY_REDIRECTS)
|
// (历史 bug:/index.html 曾返回 301 → ./,浏览器永久缓存后反复触发 ERR_TOO_MANY_REDIRECTS)
|
||||||
r.Use(func(c *gin.Context) {
|
r.Use(func(c *gin.Context) {
|
||||||
@@ -50,84 +58,141 @@ func main() {
|
|||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
api.POST("/auth/login", handler.Login)
|
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.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("/summary", handler.GetSummary)
|
||||||
api.GET("/settings", handler.GetSettings)
|
api.GET("/settings", handler.AdminOnly(), handler.GetSettings)
|
||||||
api.PUT("/settings", handler.UpdateSettings)
|
api.PUT("/settings", handler.AdminOnly(), handler.UpdateSettings)
|
||||||
api.POST("/settings/test-llm", handler.TestLLM)
|
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", handler.AiChat)
|
||||||
|
api.POST("/ai/chat/stream", handler.AiChatStream)
|
||||||
api.POST("/ai/release", handler.AiRelease(hub))
|
api.POST("/ai/release", handler.AiRelease(hub))
|
||||||
api.POST("/batch/parse", handler.BatchParse)
|
api.POST("/batch/parse", handler.BatchParse)
|
||||||
|
|
||||||
|
// 流水线:查看 viewer / 编辑配置 owner / 触发构建 operator;新建与删除仅管理员
|
||||||
api.GET("/pipelines", handler.ListPipelines)
|
api.GET("/pipelines", handler.ListPipelines)
|
||||||
api.POST("/pipelines", handler.CreatePipeline)
|
api.POST("/pipelines", handler.AdminOnly(), handler.CreatePipeline)
|
||||||
api.GET("/pipelines/:id", handler.GetPipelineDetail)
|
api.GET("/pipelines/:id", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelViewer)), handler.GetPipelineDetail)
|
||||||
api.PUT("/pipelines/:id", handler.UpdatePipeline)
|
api.PUT("/pipelines/:id", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.UpdatePipeline)
|
||||||
api.DELETE("/pipelines/:id", handler.DeletePipeline)
|
api.DELETE("/pipelines/:id", handler.AdminOnly(), handler.DeletePipeline)
|
||||||
api.PUT("/pipelines/:id/nodes", handler.SaveNodes)
|
api.PUT("/pipelines/:id/nodes", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.SaveNodes)
|
||||||
api.PUT("/pipelines/:id/edges", handler.SaveEdges)
|
api.PUT("/pipelines/:id/edges", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.SaveEdges)
|
||||||
api.POST("/pipelines/:id/runs", handler.CreateRun(hub))
|
api.POST("/pipelines/:id/runs", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOperator)), handler.CreateRun(hub))
|
||||||
api.POST("/hooks/pipeline/:id", handler.InboundPipelineHook(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", handler.ListRuns)
|
||||||
api.GET("/runs/:runId", handler.GetRun)
|
api.GET("/runs/:runId", handler.RequireRunAccess(handler.LevelRank(handler.LevelViewer)), handler.GetRun)
|
||||||
api.GET("/runs/:runId/logs/stream", handler.StreamLogs(hub))
|
api.GET("/runs/:runId/logs/stream", handler.RequireRunAccess(handler.LevelRank(handler.LevelViewer)), handler.StreamLogs(hub))
|
||||||
api.POST("/runs/:runId/retry", handler.RetryRun(hub))
|
api.POST("/runs/:runId/retry", handler.RequireRunAccess(handler.LevelRank(handler.LevelOperator)), handler.RetryRun(hub))
|
||||||
|
|
||||||
api.GET("/gitops/apps", handler.ListGitOpsApps)
|
// GitOps 部署(全局基础设施操作,未按流水线隔离)→ 管理员
|
||||||
api.GET("/gitops/argo/apps", handler.ArgoList)
|
gitops := api.Group("/gitops", handler.AdminOnly())
|
||||||
api.POST("/gitops/argo/sync", handler.ArgoSync)
|
{
|
||||||
api.GET("/gitops/argo/:appName/tree", handler.ArgoResourceTree)
|
gitops.GET("/apps", handler.ListGitOpsApps)
|
||||||
api.GET("/gitops/argo/:appName/diff", handler.ArgoDiff)
|
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)
|
plugins := api.Group("/plugins", handler.AdminOnly())
|
||||||
api.POST("/plugins/install", handler.InstallPlugin)
|
{
|
||||||
api.POST("/plugins/:id/toggle", handler.TogglePlugin)
|
plugins.GET("", handler.ListPlugins)
|
||||||
api.DELETE("/plugins/:id", handler.UninstallPlugin)
|
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)
|
// 资源(主机/集群/凭证/构建节点/工具/Webhook)与 Jenkins 集成 → 管理员
|
||||||
api.POST("/hosts", handler.CreateHost)
|
hosts := api.Group("/hosts", handler.AdminOnly())
|
||||||
api.PUT("/hosts/:id", handler.UpdateHost)
|
{
|
||||||
api.DELETE("/hosts/:id", handler.DeleteHost)
|
hosts.GET("", handler.ListHosts)
|
||||||
api.POST("/hosts/:id/test", handler.TestHost)
|
hosts.POST("", handler.CreateHost)
|
||||||
|
hosts.PUT("/:id", handler.UpdateHost)
|
||||||
api.GET("/clusters", handler.ListClusters)
|
hosts.DELETE("/:id", handler.DeleteHost)
|
||||||
api.POST("/clusters", handler.CreateCluster)
|
hosts.POST("/:id/test", handler.TestHost)
|
||||||
api.PUT("/clusters/:id", handler.UpdateCluster)
|
}
|
||||||
api.DELETE("/clusters/:id", handler.DeleteCluster)
|
clusters := api.Group("/clusters", handler.AdminOnly())
|
||||||
api.POST("/clusters/:id/test", handler.TestCluster)
|
{
|
||||||
|
clusters.GET("", handler.ListClusters)
|
||||||
api.GET("/credentials", handler.ListCredentials)
|
clusters.POST("", handler.CreateCluster)
|
||||||
api.POST("/credentials", handler.CreateCredential)
|
clusters.PUT("/:id", handler.UpdateCluster)
|
||||||
api.PUT("/credentials/:id", handler.UpdateCredential)
|
clusters.DELETE("/:id", handler.DeleteCluster)
|
||||||
api.DELETE("/credentials/:id", handler.DeleteCredential)
|
clusters.POST("/:id/test", handler.TestCluster)
|
||||||
|
}
|
||||||
api.GET("/build-nodes", handler.ListBuildNodes)
|
credentials := api.Group("/credentials", handler.AdminOnly())
|
||||||
api.POST("/build-nodes", handler.CreateBuildNode)
|
{
|
||||||
api.PUT("/build-nodes/:id", handler.UpdateBuildNode)
|
credentials.GET("", handler.ListCredentials)
|
||||||
api.DELETE("/build-nodes/:id", handler.DeleteBuildNode)
|
credentials.POST("", handler.CreateCredential)
|
||||||
api.POST("/build-nodes/:id/test", handler.TestBuildNode)
|
credentials.PUT("/:id", handler.UpdateCredential)
|
||||||
|
credentials.DELETE("/:id", handler.DeleteCredential)
|
||||||
api.GET("/tools", handler.ListTools)
|
}
|
||||||
api.PUT("/tools/:id", handler.UpdateTool)
|
buildNodes := api.Group("/build-nodes", handler.AdminOnly())
|
||||||
api.POST("/tools/:id/check", handler.CheckTool)
|
{
|
||||||
api.POST("/tools/:id/install", handler.InstallTool)
|
buildNodes.GET("", handler.ListBuildNodes)
|
||||||
|
buildNodes.POST("", handler.CreateBuildNode)
|
||||||
api.GET("/webhooks", handler.ListWebhooks)
|
buildNodes.PUT("/:id", handler.UpdateBuildNode)
|
||||||
api.POST("/webhooks", handler.CreateWebhook)
|
buildNodes.DELETE("/:id", handler.DeleteBuildNode)
|
||||||
api.PUT("/webhooks/:id", handler.UpdateWebhook)
|
buildNodes.POST("/:id/test", handler.TestBuildNode)
|
||||||
api.DELETE("/webhooks/:id", handler.DeleteWebhook)
|
buildNodes.POST("/:id/disk-test", handler.TestBuildNodeDisk)
|
||||||
api.POST("/webhooks/:id/test", handler.TestWebhook)
|
}
|
||||||
|
tools := api.Group("/tools", handler.AdminOnly())
|
||||||
api.GET("/jenkins-servers", handler.ListJenkinsServers)
|
{
|
||||||
api.POST("/jenkins-servers", handler.CreateJenkinsServer)
|
tools.GET("", handler.ListTools)
|
||||||
api.PUT("/jenkins-servers/:id", handler.UpdateJenkinsServer)
|
tools.PUT("/:id", handler.UpdateTool)
|
||||||
api.DELETE("/jenkins-servers/:id", handler.DeleteJenkinsServer)
|
tools.POST("/:id/check", handler.CheckTool)
|
||||||
api.POST("/jenkins-servers/:id/test", handler.TestJenkinsServer)
|
tools.POST("/:id/install", handler.InstallTool)
|
||||||
api.GET("/jenkins-servers/:id/jobs", handler.ListJenkinsJobs)
|
}
|
||||||
api.POST("/jenkins-servers/:id/import", handler.ImportJenkinsJobs)
|
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")
|
distFS, _ := fs.Sub(staticFS, "static")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head><meta charset="UTF-8"><title>FlowStack</title></head>
|
<head><meta charset="UTF-8"><title>FlowStack</title></head>
|
||||||
<body style="font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f6f8fa;">
|
<body style="font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f6f8fa;">
|
||||||
<div style="text-align: center; color: #57606a;">
|
<div style="text-align: center; color: #57606a;">
|
||||||
<h1 style="font-size: 20px;">FlowStack 前端尚未构建</h1>
|
<h1 style="font-size: 20px;">FlowStack 发布系统</h1>
|
||||||
<p>请在项目根目录执行 <code style="background:#eaeef2;padding:2px 6px;border-radius:4px;">./run.sh build</code> 生成前端产物后重新编译。</p>
|
<p>请在项目根目录执行 <code style="background:#eaeef2;padding:2px 6px;border-radius:4px;">./run.sh build</code> 生成前端产物后重新编译。</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# FlowStack 构建节点镜像(Ubuntu 22.04)
|
||||||
|
# 用法: docker build -f deploy/Dockerfile.buildnode -t flowstack-buildnode .
|
||||||
|
# 自定义默认 Node 版本: docker build --build-arg NODE_VERSION=18 -f deploy/Dockerfile.buildnode -t flowstack-buildnode .
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG NODE_VERSION=20
|
||||||
|
ENV NVM_DIR=/root/.nvm \
|
||||||
|
NVM_NODE_DEFAULT=${NODE_VERSION}
|
||||||
|
|
||||||
|
# 基础工具 + JDK17 + Maven + Git + Docker CLI(Ubuntu apt 一步到位)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openjdk-17-jdk-headless \
|
||||||
|
maven \
|
||||||
|
git \
|
||||||
|
docker.io \
|
||||||
|
curl ca-certificates unzip xz-utils \
|
||||||
|
tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Golang(官方 tar,apt 的 golang 版本较旧)
|
||||||
|
ARG GOLANG_VERSION=1.24.2
|
||||||
|
RUN curl -fsSL https://go.dev/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz | tar -C /usr/local -xzf - \
|
||||||
|
&& ln -s /usr/local/go/bin/go /usr/local/bin/go \
|
||||||
|
&& ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||||
|
|
||||||
|
# nvm + 默认 Node 版本(构建时可在流水线脚本里 nvm install <ver> 灵活切换)
|
||||||
|
ARG NVM_VERSION=v0.40.1
|
||||||
|
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh | bash \
|
||||||
|
&& . "$NVM_DIR/nvm.sh" \
|
||||||
|
&& nvm install ${NODE_VERSION} \
|
||||||
|
&& nvm alias default ${NODE_VERSION} \
|
||||||
|
&& echo '. "$NVM_DIR/nvm.sh"' >> /root/.bashrc
|
||||||
|
|
||||||
|
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
|
||||||
|
PATH=/usr/local/go/bin:$PATH
|
||||||
|
|
||||||
|
# 构建工作空间
|
||||||
|
RUN mkdir -p /data/flowstack-workspace
|
||||||
|
WORKDIR /data/flowstack-workspace
|
||||||
|
|
||||||
|
# 验证工具链可用
|
||||||
|
RUN java -version && mvn -version && go version && git --version \
|
||||||
|
&& . "$NVM_DIR/nvm.sh" && node -v && npm -v
|
||||||
|
|
||||||
|
CMD ["bash", "-c", "echo 'FlowStack build node ready' && bash"]
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>FlowStack · 发布系统</title>
|
<title>FlowStack · 发布系统</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+32
-51
@@ -44,6 +44,8 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.5.0",
|
"input-otp": "^1.5.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"marked": "^18.1.2",
|
||||||
|
"marked-gfm-heading-id": "^4.1.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
@@ -3650,23 +3652,6 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
|
||||||
"version": "8.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
|
||||||
"extraneous": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"fast-uri": "^3.0.1",
|
|
||||||
"json-schema-traverse": "^1.0.0",
|
|
||||||
"require-from-string": "^2.0.2"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/epoberezkin"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -4737,23 +4722,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
|
||||||
"version": "3.1.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
|
||||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
|
||||||
"extraneous": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/fastify"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fastify"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
|
||||||
@@ -4886,6 +4854,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/github-slugger": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -5136,13 +5110,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
|
||||||
"extraneous": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
@@ -5267,6 +5234,30 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "18.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.2.tgz",
|
||||||
|
"integrity": "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/marked-gfm-heading-id": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked-gfm-heading-id/-/marked-gfm-heading-id-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-CspnvVfHSkb/znqdPS4jUR8HtCjq3M/DnrsJCrfLBLvdrgbemmoINKpeWKQYkBiXAoBGejw0cV7xzqrPdup3WA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"github-slugger": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"marked": ">=13 <19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -6021,16 +6012,6 @@
|
|||||||
"decimal.js-light": "^2.4.1"
|
"decimal.js-light": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/require-from-string": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
|
||||||
"extraneous": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.13",
|
"version": "1.22.13",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz",
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.5.0",
|
"input-otp": "^1.5.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"marked": "^18.1.2",
|
||||||
|
"marked-gfm-heading-id": "^4.1.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0969da"/>
|
||||||
|
<stop offset="1" stop-color="#8250df"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#g)"/>
|
||||||
|
<g transform="translate(32 33) rotate(45) translate(-11 -13)">
|
||||||
|
<path d="M11 0 C17 4 20 10 20 17 L20 22 L14 22 C8 22 3 19 0 13 L0 0 Z" fill="#fff" opacity="0.95"/>
|
||||||
|
<circle cx="13" cy="9" r="3" fill="#0969da"/>
|
||||||
|
<path d="M0 13 L-6 20 L1 18 Z" fill="#ffd23e"/>
|
||||||
|
<path d="M20 22 L26 28 L19 27 Z" fill="#ffd23e" opacity="0.9"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 671 B |
+143
-13
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import Sidebar from '@/components/Sidebar';
|
import Sidebar from '@/components/Sidebar';
|
||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/components/TopBar';
|
||||||
import PipelineFlowEditor from '@/components/flow/PipelineFlowEditor';
|
import PipelineFlowEditor from '@/components/flow/PipelineFlowEditor';
|
||||||
@@ -16,21 +17,93 @@ import ToolConfig from '@/pages/ToolConfig';
|
|||||||
import JenkinsImport from '@/pages/JenkinsImport';
|
import JenkinsImport from '@/pages/JenkinsImport';
|
||||||
import BatchRelease from '@/pages/BatchRelease';
|
import BatchRelease from '@/pages/BatchRelease';
|
||||||
import ReleaseRecords from '@/pages/ReleaseRecords';
|
import ReleaseRecords from '@/pages/ReleaseRecords';
|
||||||
import WebhookConfig from '@/pages/WebhookConfig';
|
|
||||||
import GitOpsDeploy from '@/pages/GitOpsDeploy';
|
import GitOpsDeploy from '@/pages/GitOpsDeploy';
|
||||||
import PluginManager from '@/pages/PluginManager';
|
import PluginManager from '@/pages/PluginManager';
|
||||||
import SystemSettings from '@/pages/SystemSettings';
|
import SystemSettings from '@/pages/SystemSettings';
|
||||||
import AiAssistant from '@/components/ai/AiAssistant';
|
import AiAssistant from '@/components/ai/AiAssistant';
|
||||||
import { apiClient } from '@/lib/api';
|
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' | 'webhooks' | 'plugins' | '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 = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
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',
|
||||||
|
'/pipelines': 'pipelines',
|
||||||
|
'/gitops': 'gitops',
|
||||||
|
'/release': 'batch-release',
|
||||||
|
'/records': 'release-records',
|
||||||
|
'/environments': 'environments',
|
||||||
|
'/credentials': 'credentials',
|
||||||
|
'/build-nodes': 'build-nodes',
|
||||||
|
'/tools': 'tools',
|
||||||
|
'/jenkins-import': 'jenkins-import',
|
||||||
|
'/plugins': 'plugins',
|
||||||
|
'/users': 'users',
|
||||||
|
'/docs': 'docs',
|
||||||
|
'/settings': 'settings',
|
||||||
|
'/pipeline-roles': 'pipeline-roles',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODULE_TO_ROUTE: Record<ModuleType, string> = {
|
||||||
|
pipelines: '/pipelines',
|
||||||
|
gitops: '/gitops',
|
||||||
|
'batch-release': '/release',
|
||||||
|
'release-records': '/records',
|
||||||
|
environments: '/environments',
|
||||||
|
credentials: '/credentials',
|
||||||
|
'build-nodes': '/build-nodes',
|
||||||
|
tools: '/tools',
|
||||||
|
'jenkins-import': '/jenkins-import',
|
||||||
|
plugins: '/plugins',
|
||||||
|
users: '/users',
|
||||||
|
docs: '/docs',
|
||||||
|
settings: '/settings',
|
||||||
|
'pipeline-roles': '/pipeline-roles',
|
||||||
|
};
|
||||||
|
|
||||||
export default function App() {
|
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(() => {
|
||||||
|
if (!loggedIn && location.pathname !== '/login') {
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
}
|
||||||
|
}, [loggedIn, location.pathname, navigate]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
auth.clear();
|
||||||
|
setLoggedIn(false);
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
// Module navigation
|
// Module navigation
|
||||||
const [activeModule, setActiveModule] = useState<ModuleType>('pipelines');
|
const [activeModule, setActiveModule] = useState<ModuleType>('pipelines');
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
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
|
// Pipeline list/editor view toggle
|
||||||
const [pipelineView, setPipelineView] = useState<'list' | 'editor'>('list');
|
const [pipelineView, setPipelineView] = useState<'list' | 'editor'>('list');
|
||||||
const [currentPipelineId, setCurrentPipelineId] = useState<number>(1);
|
const [currentPipelineId, setCurrentPipelineId] = useState<number>(1);
|
||||||
@@ -38,6 +111,37 @@ export default function App() {
|
|||||||
const [saveTick, setSaveTick] = useState(0);
|
const [saveTick, setSaveTick] = useState(0);
|
||||||
const [runTick, setRunTick] = useState(0);
|
const [runTick, setRunTick] = useState(0);
|
||||||
|
|
||||||
|
// URL → 页面状态(直接输入/刷新/分享链接时恢复)
|
||||||
|
useEffect(() => {
|
||||||
|
const path = location.pathname.replace(/\/+$/, '') || '/';
|
||||||
|
// 登录页不参与模块路由(否则与未登录跳转互相重定向形成死循环)
|
||||||
|
if (path === '/login') return;
|
||||||
|
const editorMatch = path.match(/^\/pipelines\/(\d+)/);
|
||||||
|
if (editorMatch) {
|
||||||
|
setActiveModule('pipelines');
|
||||||
|
setCurrentPipelineId(Number(editorMatch[1]) || 1);
|
||||||
|
setPipelineView('editor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mod = ROUTE_TO_MODULE[path];
|
||||||
|
if (mod) {
|
||||||
|
setActiveModule(mod);
|
||||||
|
if (mod !== 'pipelines') {
|
||||||
|
setPipelineView('list');
|
||||||
|
} else if (path === '/pipelines' || path === '/') {
|
||||||
|
setPipelineView('list');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 未知路径回首页
|
||||||
|
navigate('/pipelines', { replace: true });
|
||||||
|
}, [location.pathname, navigate]);
|
||||||
|
|
||||||
|
const goToModule = useCallback((m: ModuleType) => {
|
||||||
|
setActiveModule(m);
|
||||||
|
navigate(MODULE_TO_ROUTE[m]);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
const { data: pipelines = [] } = useQuery({
|
const { data: pipelines = [] } = useQuery({
|
||||||
queryKey: ['pipelines'],
|
queryKey: ['pipelines'],
|
||||||
queryFn: apiClient.listPipelines,
|
queryFn: apiClient.listPipelines,
|
||||||
@@ -54,17 +158,21 @@ export default function App() {
|
|||||||
const currentPipeline = pipelines.find((p: any) => p.id === currentPipelineId);
|
const currentPipeline = pipelines.find((p: any) => p.id === currentPipelineId);
|
||||||
const latestStatus = currentRuns[0]?.status as string | undefined;
|
const latestStatus = currentRuns[0]?.status as string | undefined;
|
||||||
const isPipelineRunning = latestStatus === 'running' || latestStatus === 'pending';
|
const isPipelineRunning = latestStatus === 'running' || latestStatus === 'pending';
|
||||||
|
// 当前用户对该流水线的权限等级:1 查看 / 2 执行 / 3 管理 / 4 管理员(列表未加载前按 4 乐观显示,后端强校验)
|
||||||
|
const editorAccessLevel = currentPipeline?.accessLevel ?? 4;
|
||||||
|
|
||||||
const handleSelectPipeline = useCallback((id: string) => {
|
const handleSelectPipeline = useCallback((id: string) => {
|
||||||
setCurrentPipelineId(Number(id) || 1);
|
setCurrentPipelineId(Number(id) || 1);
|
||||||
setPipelineView('editor');
|
setPipelineView('editor');
|
||||||
setActiveTab('流程配置');
|
setActiveTab('流程配置');
|
||||||
}, []);
|
navigate(`/pipelines/${Number(id) || 1}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
const handlePipelineSwitch = useCallback((id: number) => {
|
const handlePipelineSwitch = useCallback((id: number) => {
|
||||||
setCurrentPipelineId(id);
|
setCurrentPipelineId(id);
|
||||||
setActiveTab('流程配置');
|
setActiveTab('流程配置');
|
||||||
}, []);
|
navigate(`/pipelines/${id}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
const handleOnlySave = useCallback(() => {
|
const handleOnlySave = useCallback(() => {
|
||||||
if (activeTab !== '流程配置') setActiveTab('流程配置');
|
if (activeTab !== '流程配置') setActiveTab('流程配置');
|
||||||
@@ -97,7 +205,8 @@ export default function App() {
|
|||||||
onSaveAndRun={handleSaveAndRun}
|
onSaveAndRun={handleSaveAndRun}
|
||||||
isRunning={isPipelineRunning}
|
isRunning={isPipelineRunning}
|
||||||
executionStatus={(latestStatus as 'running' | 'success' | 'failed' | 'cancelled') || 'idle'}
|
executionStatus={(latestStatus as 'running' | 'success' | 'failed' | 'cancelled') || 'idle'}
|
||||||
onBack={() => setPipelineView('list')}
|
onBack={() => { setPipelineView('list'); navigate('/pipelines'); }}
|
||||||
|
accessLevel={editorAccessLevel}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{activeTab === '流程配置' && (
|
{activeTab === '流程配置' && (
|
||||||
@@ -139,15 +248,19 @@ export default function App() {
|
|||||||
case 'jenkins-import':
|
case 'jenkins-import':
|
||||||
return <JenkinsImport />;
|
return <JenkinsImport />;
|
||||||
case 'batch-release':
|
case 'batch-release':
|
||||||
return <BatchRelease onViewRecords={() => setActiveModule('release-records')} />;
|
return <BatchRelease onViewRecords={() => goToModule('release-records')} />;
|
||||||
case 'release-records':
|
case 'release-records':
|
||||||
return <ReleaseRecords />;
|
return <ReleaseRecords />;
|
||||||
case 'webhooks':
|
|
||||||
return <WebhookConfig />;
|
|
||||||
case 'gitops':
|
case 'gitops':
|
||||||
return <GitOpsDeploy />;
|
return <GitOpsDeploy />;
|
||||||
case 'plugins':
|
case 'plugins':
|
||||||
return <PluginManager />;
|
return <PluginManager />;
|
||||||
|
case 'users':
|
||||||
|
return <UserManage />;
|
||||||
|
case 'pipeline-roles':
|
||||||
|
return <PipelineRoles />;
|
||||||
|
case 'docs':
|
||||||
|
return <Docs />;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <SystemSettings />;
|
return <SystemSettings />;
|
||||||
default:
|
default:
|
||||||
@@ -155,19 +268,36 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!loggedIn || location.pathname === '/login') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toaster position="top-right" richColors />
|
||||||
|
<Login
|
||||||
|
onSuccess={() => {
|
||||||
|
setLoggedIn(true);
|
||||||
|
// 刷新会话信息(切换账号后 ['me'] 缓存需要失效)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||||
|
navigate('/pipelines', { replace: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen flex overflow-hidden" style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}>
|
<div className="h-screen w-screen flex overflow-hidden" style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}>
|
||||||
<Toaster position="top-right" richColors />
|
<Toaster position="top-right" richColors />
|
||||||
<Sidebar
|
<Sidebar
|
||||||
activeModule={activeModule}
|
activeModule={activeModule}
|
||||||
onModuleChange={setActiveModule}
|
onModuleChange={goToModule}
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
<AiAssistant onNavigate={(m) => setActiveModule(m as ModuleType)} />
|
<AiAssistant onNavigate={(m) => goToModule(m as ModuleType)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,8 @@ export default function BasicInfoPanel({ pipelineId }: Props) {
|
|||||||
queryFn: () => apiClient.getPipeline(pipelineId),
|
queryFn: () => apiClient.getPipeline(pipelineId),
|
||||||
});
|
});
|
||||||
const p = detail?.pipeline;
|
const p = detail?.pipeline;
|
||||||
|
// 编辑权限:「管理」等级及以上才能保存
|
||||||
|
const canEdit = (detail?.accessLevel ?? 4) >= 3;
|
||||||
|
|
||||||
const [form, setForm] = useState({ name: '', description: '', sourceRepo: '', sourceBranch: '', deployType: 'host', env: 'staging', notifyWebhookURL: '' });
|
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>
|
||||||
<div className="flex items-center gap-3">
|
<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>}
|
{p && <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>更新于 {new Date(p.updatedAt).toLocaleString('zh-CN', { hour12: false })}</span>}
|
||||||
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => saveMutation.mutate(form)}
|
onClick={() => saveMutation.mutate(form)}
|
||||||
disabled={saveMutation.isPending || !form.name}
|
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" />}保存
|
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,7 +92,7 @@ export default function BasicInfoPanel({ pipelineId }: Props) {
|
|||||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>部署方式</label>
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>部署方式</label>
|
||||||
<select value={form.deployType} onChange={(e) => set('deployType', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle}>
|
<select value={form.deployType} onChange={(e) => set('deployType', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle}>
|
||||||
<option value="host">主机部署</option>
|
<option value="host">主机部署</option>
|
||||||
<option value="k8s">K8s 部署</option>
|
<option value="k8s">Kubernetes 部署</option>
|
||||||
<option value="gitops">GitOps</option>
|
<option value="gitops">GitOps</option>
|
||||||
</select>
|
</select>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CheckCircle2, XCircle, Loader2, Clock, RotateCcw, ExternalLink, Rocket } from 'lucide-react';
|
import { CheckCircle2, XCircle, Loader2, Clock, RotateCcw, ExternalLink, Rocket, Sparkles } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api';
|
import { apiClient, type PipelineRunDTO } from '@/lib/api';
|
||||||
import { usePipelineLogs } from '@/hooks/usePipelineLogs';
|
import { usePipelineLogs } from '@/hooks/usePipelineLogs';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pipelineId: number;
|
pipelineId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 列表行:PipelineRunDTO + 失败时自动生成的 AI 分析字段
|
||||||
|
type RunRow = PipelineRunDTO & { analysis?: string };
|
||||||
|
|
||||||
const statusMap: Record<string, { color: string; bg: string; label: string }> = {
|
const statusMap: Record<string, { color: string; bg: string; label: string }> = {
|
||||||
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
||||||
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
||||||
@@ -32,7 +35,7 @@ export default function ReleasePanel({ pipelineId }: Props) {
|
|||||||
|
|
||||||
const { data: runs = [], isLoading } = useQuery({
|
const { data: runs = [], isLoading } = useQuery({
|
||||||
queryKey: ['runs', { pipelineId }],
|
queryKey: ['runs', { pipelineId }],
|
||||||
queryFn: () => apiClient.listRuns({ pipelineId }),
|
queryFn: () => apiClient.listRuns({ pipelineId }) as Promise<RunRow[]>,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: detail } = useQuery({
|
const { data: detail } = useQuery({
|
||||||
@@ -41,6 +44,13 @@ export default function ReleasePanel({ pipelineId }: Props) {
|
|||||||
enabled: !!expandedRunId,
|
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 expandedRun = runs.find((r) => r.runId === expandedRunId);
|
||||||
const isRunning = expandedRun?.status === 'running' || expandedRun?.status === 'pending';
|
const isRunning = expandedRun?.status === 'running' || expandedRun?.status === 'pending';
|
||||||
const { logs: liveLogs, connected } = usePipelineLogs(expandedRunId, !!expandedRunId && isRunning);
|
const { logs: liveLogs, connected } = usePipelineLogs(expandedRunId, !!expandedRunId && isRunning);
|
||||||
@@ -101,7 +111,7 @@ export default function ReleasePanel({ pipelineId }: Props) {
|
|||||||
<span className="text-[11px] w-36 text-right" style={{ color: 'var(--text-tertiary)' }}>
|
<span className="text-[11px] w-36 text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{new Date(r.startedAt).toLocaleString('zh-CN', { hour12: false })}
|
{new Date(r.startedAt).toLocaleString('zh-CN', { hour12: false })}
|
||||||
</span>
|
</span>
|
||||||
{(r.status === 'failed' || r.status === 'cancelled') && (
|
{(r.status === 'failed' || r.status === 'cancelled') && canRun && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); rerunMutation.mutate(r.runId); }}
|
onClick={(e) => { e.stopPropagation(); rerunMutation.mutate(r.runId); }}
|
||||||
className="p-1 rounded hover:bg-black/5"
|
className="p-1 rounded hover:bg-black/5"
|
||||||
@@ -132,6 +142,19 @@ export default function ReleasePanel({ pipelineId }: Props) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* AI 失败分析(紧凑版,日志区上方) */}
|
||||||
|
{r.analysis ? (
|
||||||
|
<div className="mb-2 rounded-md border px-3 py-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
|
||||||
|
<div className="flex items-center gap-1 text-[11px] font-semibold mb-1" style={{ color: '#8250df' }}>
|
||||||
|
<Sparkles className="w-3 h-3" />AI 失败分析
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] leading-[18px] whitespace-pre-wrap" style={{ color: 'var(--text-primary)' }}>{r.analysis}</div>
|
||||||
|
</div>
|
||||||
|
) : ['running', 'pending', 'queued'].includes(r.status) ? (
|
||||||
|
<div className="mb-2 flex items-center gap-1 text-[10px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Sparkles className="w-3 h-3" style={{ color: '#8250df' }} />失败后将自动生成 AI 分析
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)' }}>
|
<div className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)' }}>
|
||||||
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color-light)' }}>
|
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
<span className="text-[11px] font-semibold" style={{ color: 'var(--text-primary)' }}>执行日志</span>
|
<span className="text-[11px] font-semibold" style={{ color: 'var(--text-primary)' }}>执行日志</span>
|
||||||
|
|||||||
@@ -10,71 +10,78 @@ import {
|
|||||||
Rocket,
|
Rocket,
|
||||||
Zap,
|
Zap,
|
||||||
History,
|
History,
|
||||||
Webhook,
|
|
||||||
CloudSync,
|
CloudSync,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Settings
|
Settings,
|
||||||
|
BookOpen,
|
||||||
|
Users,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
LogOut,
|
||||||
|
ShieldCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useTheme } from '@/providers/theme';
|
||||||
import { apiClient } from '@/lib/api';
|
import { useSession } from '@/providers/session';
|
||||||
|
import type { ModuleType } from '@/App';
|
||||||
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeModule: ModuleType;
|
activeModule: ModuleType;
|
||||||
onModuleChange: (module: ModuleType) => void;
|
onModuleChange: (module: ModuleType) => void;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: typeof GitBranch; badge: string | null }[] }[] = [
|
// adminOnly:普通用户隐藏(资源/集成含敏感配置,系统管理类同理)
|
||||||
|
const menuGroups: { label: string; adminOnly?: boolean; items: { id: ModuleType; label: string; icon: typeof GitBranch; adminOnly?: boolean }[] }[] = [
|
||||||
{
|
{
|
||||||
label: '发布',
|
label: '发布',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'pipelines', label: '流水线', icon: GitBranch, badge: null },
|
{ id: 'pipelines', label: '流水线', icon: GitBranch },
|
||||||
{ id: 'gitops', label: 'GitOps 部署', icon: CloudSync, badge: 'New' },
|
{ id: 'batch-release', label: '批量发布', icon: Zap },
|
||||||
{ id: 'batch-release', label: '批量发布', icon: Zap, badge: null },
|
{ id: 'release-records', label: '发布记录', icon: History },
|
||||||
{ id: 'release-records', label: '发布记录', icon: History, badge: null },
|
{ id: 'gitops', label: 'GitOps 部署', icon: CloudSync, adminOnly: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '资源',
|
label: '资源',
|
||||||
|
adminOnly: true,
|
||||||
items: [
|
items: [
|
||||||
{ id: 'environments', label: '环境管理', icon: Server, badge: null },
|
{ id: 'environments', label: '环境管理', icon: Server },
|
||||||
{ id: 'credentials', label: '凭证管理', icon: Key, badge: null },
|
{ id: 'credentials', label: '凭证管理', icon: Key },
|
||||||
{ id: 'build-nodes', label: '构建节点', icon: Cpu, badge: '3' },
|
{ id: 'build-nodes', label: '构建节点', icon: Cpu },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '集成',
|
label: '集成',
|
||||||
|
adminOnly: true,
|
||||||
items: [
|
items: [
|
||||||
{ id: 'jenkins-import', label: 'Jenkins导入', icon: Download, badge: null },
|
{ id: 'jenkins-import', label: 'Jenkins导入', icon: Download },
|
||||||
{ id: 'webhooks', label: 'Webhook', icon: Webhook, badge: null },
|
{ id: 'tools', label: '工具配置', icon: Wrench },
|
||||||
{ id: 'tools', label: '工具配置', icon: Wrench, badge: null },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '系统',
|
label: '系统',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'plugins', label: '插件市场', icon: Puzzle, badge: '12' },
|
{ id: 'docs', label: '文档中心', icon: BookOpen },
|
||||||
{ id: 'settings', label: '系统设置', icon: Settings, badge: null },
|
{ 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 }: SidebarProps) {
|
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse, onLogout }: SidebarProps) {
|
||||||
// 徽标数来自后端真实统计
|
const { toggle: themeToggle, isDark } = useTheme();
|
||||||
const { data: summary } = useQuery({
|
const { isAdmin } = useSession();
|
||||||
queryKey: ['summary'],
|
|
||||||
queryFn: apiClient.getSummary,
|
|
||||||
refetchInterval: 15000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const badgeFor = (id: ModuleType): string | null => {
|
const visibleGroups = menuGroups
|
||||||
if (id === 'build-nodes') return summary?.buildNodes != null ? String(summary.buildNodes) : null;
|
.map((group) => ({
|
||||||
if (id === 'plugins') return summary?.plugins != null ? String(summary.plugins) : null;
|
...group,
|
||||||
return null;
|
items: group.items.filter((item) => isAdmin || !item.adminOnly),
|
||||||
};
|
}))
|
||||||
|
.filter((group) => isAdmin || !group.adminOnly);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -105,7 +112,7 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog
|
|||||||
|
|
||||||
{/* Menu groups */}
|
{/* Menu groups */}
|
||||||
<div className="flex-1 py-2 overflow-y-auto custom-scrollbar">
|
<div className="flex-1 py-2 overflow-y-auto custom-scrollbar">
|
||||||
{menuGroups.map((group) => (
|
{visibleGroups.map((group) => (
|
||||||
<div key={group.label} className="mb-2">
|
<div key={group.label} className="mb-2">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div
|
<div
|
||||||
@@ -132,26 +139,9 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog
|
|||||||
>
|
>
|
||||||
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<>
|
|
||||||
<span className="text-[13px] font-medium truncate flex-1 text-left">
|
<span className="text-[13px] font-medium truncate flex-1 text-left">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
|
||||||
const badge = item.badge === 'New' ? 'New' : badgeFor(item.id);
|
|
||||||
if (!badge) return null;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="text-[9px] px-1 py-[1px] rounded font-medium"
|
|
||||||
style={{
|
|
||||||
background: badge === 'New' ? '#ddf4ff' : 'var(--panel-bg-secondary)',
|
|
||||||
color: badge === 'New' ? 'var(--accent-blue)' : 'var(--text-tertiary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{isActive && !collapsed && (
|
{isActive && !collapsed && (
|
||||||
<div
|
<div
|
||||||
@@ -167,11 +157,27 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collapse toggle */}
|
{/* Bottom bar: theme toggle + collapse */}
|
||||||
<div
|
<div
|
||||||
className="px-2 py-2 border-t flex justify-center"
|
className="px-2 py-2 border-t flex items-center justify-center gap-1"
|
||||||
style={{ borderColor: 'var(--border-color)' }}
|
style={{ borderColor: 'var(--border-color)' }}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={themeToggle}
|
||||||
|
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
title="切换深色/浅色主题"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||||
|
|||||||
@@ -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 { useState } from 'react';
|
||||||
|
import PipelineMembersDialog from './PipelineMembersDialog';
|
||||||
|
|
||||||
type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ interface TopBarProps {
|
|||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
executionStatus: 'idle' | 'running' | 'success' | 'failed' | 'cancelled';
|
executionStatus: 'idle' | 'running' | 'success' | 'failed' | 'cancelled';
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
|
accessLevel?: number; // 1 查看 / 2 执行 / 3 管理 / 4 管理员
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: EditorTab[] = ['基本信息', '流程配置', '触发设置', '变量和缓存', '发布'];
|
const tabs: EditorTab[] = ['基本信息', '流程配置', '触发设置', '变量和缓存', '发布'];
|
||||||
@@ -38,8 +40,10 @@ export default function TopBar({
|
|||||||
isRunning = false,
|
isRunning = false,
|
||||||
executionStatus,
|
executionStatus,
|
||||||
onBack,
|
onBack,
|
||||||
|
accessLevel = 4,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
const [pipelineDropdownOpen, setPipelineDropdownOpen] = useState(false);
|
const [pipelineDropdownOpen, setPipelineDropdownOpen] = useState(false);
|
||||||
|
const [membersOpen, setMembersOpen] = useState(false);
|
||||||
|
|
||||||
const statusConfig: Record<string, { color: string; bg: string; label: string } | null> = {
|
const statusConfig: Record<string, { color: string; bg: string; label: string } | null> = {
|
||||||
idle: null,
|
idle: null,
|
||||||
@@ -146,8 +150,24 @@ export default function TopBar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Action Buttons */}
|
{/* Right: Action Buttons(按权限等级显隐:保存≥管理、运行≥执行、成员≥管理) */}
|
||||||
<div className="flex items-center gap-2 min-w-[280px] justify-end">
|
<div className="flex items-center gap-2 min-w-[280px] justify-end">
|
||||||
|
{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
|
<button
|
||||||
onClick={onOnlySave}
|
onClick={onOnlySave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -161,6 +181,8 @@ export default function TopBar({
|
|||||||
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
仅保存
|
仅保存
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{accessLevel >= 2 && (
|
||||||
<button
|
<button
|
||||||
onClick={onSaveAndRun}
|
onClick={onSaveAndRun}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
@@ -170,7 +192,14 @@ export default function TopBar({
|
|||||||
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||||
{isRunning ? '运行中…' : '保存并运行'}
|
{isRunning ? '运行中…' : '保存并运行'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<PipelineMembersDialog
|
||||||
|
pipelineId={currentPipelineId}
|
||||||
|
pipelineName={pipelineName}
|
||||||
|
open={membersOpen}
|
||||||
|
onClose={() => setMembersOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,8 @@ export default function TriggerPanel({ pipelineId }: Props) {
|
|||||||
queryFn: () => apiClient.getPipeline(pipelineId),
|
queryFn: () => apiClient.getPipeline(pipelineId),
|
||||||
});
|
});
|
||||||
const p = detail?.pipeline;
|
const p = detail?.pipeline;
|
||||||
|
// 编辑权限:「管理」等级及以上才能保存
|
||||||
|
const canEdit = (detail?.accessLevel ?? 4) >= 3;
|
||||||
|
|
||||||
const [form, setForm] = useState({ triggerMode: 'manual', cronExpr: '', webhookToken: '', watchBranches: '' });
|
const [form, setForm] = useState({ triggerMode: 'manual', cronExpr: '', webhookToken: '', watchBranches: '' });
|
||||||
const [copied, setCopied] = useState(false);
|
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="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">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>触发方式</h3>
|
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>触发方式</h3>
|
||||||
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => saveMutation.mutate(form)}
|
onClick={() => saveMutation.mutate(form)}
|
||||||
disabled={saveMutation.isPending}
|
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" />}保存
|
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export default function VariablesPanel({ pipelineId }: Props) {
|
|||||||
queryFn: () => apiClient.getPipeline(pipelineId),
|
queryFn: () => apiClient.getPipeline(pipelineId),
|
||||||
});
|
});
|
||||||
const p = detail?.pipeline;
|
const p = detail?.pipeline;
|
||||||
|
// 编辑权限:「管理」等级及以上才能保存
|
||||||
|
const canEdit = (detail?.accessLevel ?? 4) >= 3;
|
||||||
|
|
||||||
const [vars, setVars] = useState<VarItem[]>([]);
|
const [vars, setVars] = useState<VarItem[]>([]);
|
||||||
const [caches, setCaches] = useState<CacheItem[]>([]);
|
const [caches, setCaches] = useState<CacheItem[]>([]);
|
||||||
@@ -105,9 +107,13 @@ export default function VariablesPanel({ pipelineId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end">
|
<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)' }}>
|
<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" />}保存全部
|
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存全部
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>只读(需要「管理」权限才能编辑)</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Sparkles, Send, X, Loader2, CheckCircle2, XCircle, History, Bot } from 'lucide-react';
|
import { Sparkles, Send, X, Loader2, CheckCircle2, XCircle, History, Bot, Square } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api';
|
import { apiClient, auth } from '@/lib/api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onNavigate?: (module: string) => void;
|
onNavigate?: (module: string) => void;
|
||||||
@@ -11,6 +11,9 @@ interface Props {
|
|||||||
interface ChatItem {
|
interface ChatItem {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
|
reasoning?: string;
|
||||||
|
streaming?: boolean;
|
||||||
|
stopped?: boolean;
|
||||||
action?: any; // {type:'release', jobs, branch, env}
|
action?: any; // {type:'release', jobs, branch, env}
|
||||||
actionState?: 'pending' | 'done' | 'cancelled';
|
actionState?: 'pending' | 'done' | 'cancelled';
|
||||||
result?: { triggered: any[]; failed: any[] };
|
result?: { triggered: any[]; failed: any[] };
|
||||||
@@ -21,6 +24,21 @@ const suggestions = [
|
|||||||
'把后端两个服务发到 pre',
|
'把后端两个服务发到 pre',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 解析一个 SSE 事件块(不含结尾空行):event: xxx / data: {...}
|
||||||
|
function parseSSEChunk(chunk: string): { event: string; data: string } | null {
|
||||||
|
let event = 'message';
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
for (const line of chunk.split('\n')) {
|
||||||
|
if (!line || line.startsWith(':')) continue; // 注释/空行
|
||||||
|
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||||
|
else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
|
||||||
|
}
|
||||||
|
if (dataLines.length === 0) return null;
|
||||||
|
return { event, data: dataLines.join('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isJsonInstruction = (s: string) => s.trimStart().startsWith('{');
|
||||||
|
|
||||||
export default function AiAssistant({ onNavigate }: Props) {
|
export default function AiAssistant({ onNavigate }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
@@ -29,6 +47,7 @@ export default function AiAssistant({ onNavigate }: Props) {
|
|||||||
{ role: 'assistant', content: '你好,我是 FlowStack 智能发布助手。告诉我你要发布哪些服务(支持自然语言),例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。' },
|
{ role: 'assistant', content: '你好,我是 FlowStack 智能发布助手。告诉我你要发布哪些服务(支持自然语言),例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。' },
|
||||||
]);
|
]);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: settings } = useQuery({
|
const { data: settings } = useQuery({
|
||||||
@@ -49,20 +68,122 @@ export default function AiAssistant({ onNavigate }: Props) {
|
|||||||
if (!content || sending) return;
|
if (!content || sending) return;
|
||||||
setInput('');
|
setInput('');
|
||||||
const userMsg: ChatItem = { role: 'user', content };
|
const userMsg: ChatItem = { role: 'user', content };
|
||||||
const next = [...messages, userMsg];
|
const assistantIndex = messages.length + 1; // 占位 assistant 气泡下标
|
||||||
setMessages(next);
|
const history = [...messages, userMsg].slice(-10).map((m) => ({ role: m.role, content: m.content }));
|
||||||
|
setMessages((prev) => [...prev, userMsg, { role: 'assistant', content: '', streaming: true }]);
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
const patch = (updater: (m: ChatItem) => ChatItem) => {
|
||||||
|
setMessages((prev) => prev.map((m, i) => (i === assistantIndex ? updater(m) : m)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let accContent = '';
|
||||||
|
let accReasoning = '';
|
||||||
|
let gotDone = false;
|
||||||
|
|
||||||
|
const applyDone = (data: { reply?: string; action?: any }) => {
|
||||||
|
gotDone = true;
|
||||||
|
const reply = typeof data?.reply === 'string' && data.reply ? data.reply : accContent;
|
||||||
|
patch((m) => ({
|
||||||
|
...m,
|
||||||
|
content: reply,
|
||||||
|
action: data?.action,
|
||||||
|
actionState: data?.action ? 'pending' : undefined,
|
||||||
|
streaming: false,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const history = next.slice(-10).map((m) => ({ role: m.role, content: m.content }));
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
const res = await apiClient.aiChat(history);
|
const token = auth.getToken();
|
||||||
setMessages((prev) => [...prev, { role: 'assistant', content: res.reply, action: res.action, actionState: res.action ? 'pending' : undefined }]);
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
const res = await fetch('/api/ai/chat/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ messages: history }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
auth.clear();
|
||||||
|
if (!window.location.pathname.startsWith('/login')) window.location.href = '/login';
|
||||||
|
throw new Error('登录已失效');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `请求失败 (HTTP ${res.status})`;
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body?.error) message = body.error;
|
||||||
|
} catch {
|
||||||
|
// 非 JSON 响应体
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctype = res.headers.get('content-type') || '';
|
||||||
|
if (!ctype.includes('text/event-stream')) {
|
||||||
|
// 后端未配置 LLM:直接返回普通 JSON {reply, viaAI:false, action}
|
||||||
|
const data = await res.json();
|
||||||
|
applyDone(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手写 SSE 解析:事件以空行分隔
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
|
||||||
|
const handleChunk = (chunk: string) => {
|
||||||
|
const parsed = parseSSEChunk(chunk);
|
||||||
|
if (!parsed) return;
|
||||||
|
let payload: any = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(parsed.data);
|
||||||
|
} catch {
|
||||||
|
payload = { content: parsed.data };
|
||||||
|
}
|
||||||
|
if (parsed.event === 'delta') {
|
||||||
|
if (typeof payload?.content === 'string' && payload.content) accContent += payload.content;
|
||||||
|
if (typeof payload?.reasoning === 'string' && payload.reasoning) accReasoning += payload.reasoning;
|
||||||
|
patch((m) => ({ ...m, content: accContent, reasoning: accReasoning }));
|
||||||
|
} else if (parsed.event === 'done') {
|
||||||
|
applyDone(payload);
|
||||||
|
}
|
||||||
|
// start / 其他事件:无需处理
|
||||||
|
};
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
let idx: number;
|
||||||
|
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
||||||
|
const chunk = buf.slice(0, idx);
|
||||||
|
buf = buf.slice(idx + 2);
|
||||||
|
if (chunk.trim()) handleChunk(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buf.trim()) handleChunk(buf.trim()); // 冲刷结尾未跟空行的残包
|
||||||
|
if (!gotDone) patch((m) => ({ ...m, streaming: false }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessages((prev) => [...prev, { role: 'assistant', content: '请求失败:' + (err as Error).message }]);
|
if ((err as any)?.name === 'AbortError') {
|
||||||
|
// 用户主动停止:保留已生成内容
|
||||||
|
patch((m) => ({ ...m, content: accContent || m.content, streaming: false, stopped: true }));
|
||||||
|
} else if (accContent) {
|
||||||
|
// 中途断流:保留已有内容并标记
|
||||||
|
patch((m) => ({ ...m, content: accContent, streaming: false, stopped: true }));
|
||||||
|
} else {
|
||||||
|
patch(() => ({ role: 'assistant', content: '请求失败:' + (err as Error).message }));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
abortRef.current = null;
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopStream = () => abortRef.current?.abort();
|
||||||
|
|
||||||
const confirmRelease = async (index: number) => {
|
const confirmRelease = async (index: number) => {
|
||||||
const msg = messages[index];
|
const msg = messages[index];
|
||||||
if (!msg?.action) return;
|
if (!msg?.action) return;
|
||||||
@@ -106,7 +227,7 @@ export default function AiAssistant({ onNavigate }: Props) {
|
|||||||
{/* 对话面板 */}
|
{/* 对话面板 */}
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-22 right-6 z-50 w-[400px] rounded-xl border shadow-2xl flex flex-col overflow-hidden"
|
className="fixed right-6 z-50 w-[400px] rounded-xl border shadow-2xl flex flex-col overflow-hidden"
|
||||||
style={{ bottom: 84, height: 520, background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
style={{ bottom: 84, height: 520, background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
>
|
>
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
@@ -139,8 +260,35 @@ export default function AiAssistant({ onNavigate }: Props) {
|
|||||||
: { background: 'var(--panel-bg-secondary)', color: 'var(--text-primary)', borderBottomLeftRadius: 4 }
|
: { background: 'var(--panel-bg-secondary)', color: 'var(--text-primary)', borderBottomLeftRadius: 4 }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{m.content}
|
{/* reasoning 增量:灰色小字(仅推理过程中展示,最近 120 字符) */}
|
||||||
|
{m.role === 'assistant' && m.streaming && !!m.reasoning && (
|
||||||
|
<div className="italic text-[10px] leading-4 mb-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{m.reasoning.length > 120 ? '…' : ''}{m.reasoning.slice(-120)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
if (m.role === 'assistant' && m.streaming && isJsonInstruction(m.content)) {
|
||||||
|
// 指令解析 JSON:不展示原始流
|
||||||
|
return <span className="inline-flex items-center gap-1 animate-pulse">📋 解析发布指令中…</span>;
|
||||||
|
}
|
||||||
|
if (m.role === 'assistant' && !m.streaming && isJsonInstruction(m.content) && m.action) {
|
||||||
|
return <span>已识别发布指令,请确认:</span>;
|
||||||
|
}
|
||||||
|
if (m.role === 'assistant' && m.streaming && !m.content && !m.reasoning) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />思考中…
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return m.content;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.stopped && (
|
||||||
|
<div className="mt-1 text-[10px] italic" style={{ color: 'var(--text-tertiary)' }}>已停止</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 发布计划卡片 */}
|
{/* 发布计划卡片 */}
|
||||||
{m.action && m.action.type === 'release' && (
|
{m.action && m.action.type === 'release' && (
|
||||||
@@ -215,14 +363,6 @@ export default function AiAssistant({ onNavigate }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{sending && (
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<div className="px-3 py-2 rounded-xl flex items-center gap-1.5" style={{ background: 'var(--panel-bg-secondary)' }}>
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--accent-blue)' }} />
|
|
||||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>思考中…</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 快捷示例 */}
|
{/* 快捷示例 */}
|
||||||
@@ -251,14 +391,25 @@ export default function AiAssistant({ onNavigate }: Props) {
|
|||||||
className="flex-1 px-3 py-2 text-[12px] rounded-lg border outline-none"
|
className="flex-1 px-3 py-2 text-[12px] rounded-lg border outline-none"
|
||||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
/>
|
/>
|
||||||
|
{sending ? (
|
||||||
|
<button
|
||||||
|
onClick={stopStream}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-white flex-shrink-0"
|
||||||
|
style={{ background: '#cf222e' }}
|
||||||
|
title="停止生成"
|
||||||
|
>
|
||||||
|
<Square className="w-3 h-3" fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => send()}
|
onClick={() => send()}
|
||||||
disabled={sending || !input.trim()}
|
disabled={!input.trim()}
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white disabled:opacity-40"
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-white disabled:opacity-40"
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
>
|
>
|
||||||
<Send className="w-3.5 h-3.5" />
|
<Send className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import TektonNode, { type TektonNodeData } from './TektonNode';
|
import TektonNode, { type TektonNodeData } from './TektonNode';
|
||||||
import { apiClient } from '@/lib/api';
|
import { apiClient, auth } from '@/lib/api';
|
||||||
|
|
||||||
interface FlowEditorProps {
|
interface FlowEditorProps {
|
||||||
pipelineId: number;
|
pipelineId: number;
|
||||||
@@ -177,7 +177,9 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit
|
|||||||
setAllNodeStatus('pending');
|
setAllNodeStatus('pending');
|
||||||
toast.success(`运行已触发 ${run.runId}`, { description: `${nodes.length} 个节点开始执行` });
|
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) => {
|
es.addEventListener('log', (evt) => {
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse((evt as MessageEvent).data);
|
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 库
|
{catalogOpen ? <ChevronLeft className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}Task 库
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-1.5">
|
<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)' }}>
|
<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" />保存
|
<Save className="w-3 h-3" />保存
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{(pipelineData?.accessLevel ?? 4) >= 2 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRun}
|
onClick={handleRun}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
@@ -355,6 +360,7 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit
|
|||||||
? <><CircleDot className="w-3 h-3 animate-spin" />执行中…</>
|
? <><CircleDot className="w-3 h-3 animate-spin" />执行中…</>
|
||||||
: <><Play className="w-3 h-3" />执行</>}
|
: <><Play className="w-3 h-3" />执行</>}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
+10
-11
@@ -49,7 +49,7 @@ const formatTime = (v: string | null | undefined) => {
|
|||||||
|
|
||||||
const emptyForm = { name: '', url: '', secret: '', events: [] as string[] };
|
const emptyForm = { name: '', url: '', secret: '', events: [] as string[] };
|
||||||
|
|
||||||
export default function WebhookConfig() {
|
export default function WebhookManager() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
@@ -137,14 +137,13 @@ export default function WebhookConfig() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
{/* Header */}
|
{/* Card 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="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>Webhook 配置</h1>
|
<Webhook className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
|
||||||
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>Webhook 通知</span>
|
||||||
{webhooks.length}
|
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>发布事件回调第三方(HMAC-SHA256 签名)</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowForm(true); setEditingId(null); setEditingSecretSet(false); setForm(emptyForm); }}
|
onClick={() => { setShowForm(true); setEditingId(null); setEditingSecretSet(false); setForm(emptyForm); }}
|
||||||
@@ -155,8 +154,8 @@ export default function WebhookConfig() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
<div className="p-5">
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="rounded-lg border p-4 mb-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border p-4 mb-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>{editingId != null ? '编辑 Webhook' : '新建 Webhook'}</h3>
|
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>{editingId != null ? '编辑 Webhook' : '新建 Webhook'}</h3>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { auth } from '@/lib/api';
|
||||||
|
|
||||||
export interface LogMessage {
|
export interface LogMessage {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -26,7 +27,9 @@ export function usePipelineLogs(runId: string | null, enabled: boolean) {
|
|||||||
return;
|
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('connected', () => setConnected(true));
|
||||||
es.addEventListener('log', (evt) => {
|
es.addEventListener('log', (evt) => {
|
||||||
|
|||||||
@@ -351,3 +351,25 @@ textarea:focus {
|
|||||||
.context-menu-item-danger:hover {
|
.context-menu-item-danger:hover {
|
||||||
background: rgba(207, 34, 46, 0.08);
|
background: rgba(207, 34, 46, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 文档页 Markdown 排版 ---------- */
|
||||||
|
.md-body { font-size: 13px; line-height: 1.75; color: var(--text-primary); }
|
||||||
|
.md-body h1 { font-size: 20px; font-weight: 700; margin: 24px 0 12px; }
|
||||||
|
.md-body h2 { font-size: 16px; font-weight: 700; margin: 22px 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border-color-light); scroll-margin-top: 16px; }
|
||||||
|
.md-body h3 { font-size: 14px; font-weight: 600; margin: 16px 0 8px; scroll-margin-top: 16px; }
|
||||||
|
.md-body h4 { font-size: 13px; font-weight: 600; margin: 12px 0 6px; }
|
||||||
|
.md-body p { margin: 8px 0; color: var(--text-secondary); }
|
||||||
|
.md-body strong { color: var(--text-primary); font-weight: 600; }
|
||||||
|
.md-body ul, .md-body ol { margin: 8px 0; padding-left: 22px; color: var(--text-secondary); }
|
||||||
|
.md-body li { margin: 4px 0; }
|
||||||
|
.md-body a { color: var(--accent-blue); text-decoration: none; }
|
||||||
|
.md-body a:hover { text-decoration: underline; }
|
||||||
|
.md-body blockquote { margin: 10px 0; padding: 8px 14px; border-left: 3px solid var(--accent-blue); background: var(--panel-bg-secondary); border-radius: 0 6px 6px 0; color: var(--text-secondary); }
|
||||||
|
.md-body code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; background: var(--panel-bg-secondary); border: 1px solid var(--border-color-light); border-radius: 4px; padding: 1px 5px; color: var(--accent-purple); }
|
||||||
|
.md-body pre { margin: 10px 0; padding: 12px 14px; background: var(--panel-bg-secondary); border: 1px solid var(--border-color-light); border-radius: 8px; overflow-x: auto; }
|
||||||
|
.md-body pre code { background: transparent; border: none; padding: 0; color: var(--text-primary); font-size: 11.5px; line-height: 1.6; }
|
||||||
|
.md-body table { width: 100%; margin: 10px 0; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.md-body th { text-align: left; padding: 7px 10px; background: var(--panel-bg-secondary); color: var(--text-tertiary); font-weight: 600; border: 1px solid var(--border-color-light); white-space: nowrap; }
|
||||||
|
.md-body td { padding: 6px 10px; border: 1px solid var(--border-color-light); color: var(--text-secondary); vertical-align: top; }
|
||||||
|
.md-body tr:nth-child(even) td { background: color-mix(in srgb, var(--panel-bg-secondary) 45%, transparent); }
|
||||||
|
.md-body hr { border: none; border-top: 1px solid var(--border-color-light); margin: 18px 0; }
|
||||||
+47
-1
@@ -7,11 +7,30 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'flowstack-token'
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
getToken: () => localStorage.getItem(TOKEN_KEY),
|
||||||
|
setToken: (t: string) => localStorage.setItem(TOKEN_KEY, t),
|
||||||
|
clear: () => localStorage.removeItem(TOKEN_KEY),
|
||||||
|
}
|
||||||
|
|
||||||
export async function api<T = any>(path: string, init?: RequestInit): Promise<T> {
|
export async function api<T = any>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
const token = auth.getToken()
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
headers: { ...headers, ...(init?.headers as Record<string, string> || {}) },
|
||||||
...init,
|
...init,
|
||||||
})
|
})
|
||||||
|
if (res.status === 401 && !path.includes('/auth/login')) {
|
||||||
|
// 登录失效:清理并跳登录页(避免循环)
|
||||||
|
auth.clear()
|
||||||
|
if (!window.location.pathname.startsWith('/login')) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
throw new ApiError('登录已失效', 401)
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let message = `请求失败 (HTTP ${res.status})`
|
let message = `请求失败 (HTTP ${res.status})`
|
||||||
try {
|
try {
|
||||||
@@ -55,6 +74,7 @@ export interface PipelineDetail {
|
|||||||
pipeline: any
|
pipeline: any
|
||||||
nodes: PipelineNodeDTO[]
|
nodes: PipelineNodeDTO[]
|
||||||
edges: PipelineEdgeDTO[]
|
edges: PipelineEdgeDTO[]
|
||||||
|
accessLevel?: number // 1 查看 / 2 执行 / 3 管理 / 4 管理员
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipelineRunDTO {
|
export interface PipelineRunDTO {
|
||||||
@@ -125,6 +145,7 @@ export const apiClient = {
|
|||||||
updateBuildNode: (id: any, b: any) => api(`/api/build-nodes/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
updateBuildNode: (id: any, b: any) => api(`/api/build-nodes/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||||
deleteBuildNode: (id: any) => api(`/api/build-nodes/${id}`, { method: 'DELETE' }),
|
deleteBuildNode: (id: any) => api(`/api/build-nodes/${id}`, { method: 'DELETE' }),
|
||||||
testBuildNode: (id: any) => api<any>(`/api/build-nodes/${id}/test`, { method: 'POST' }),
|
testBuildNode: (id: any) => api<any>(`/api/build-nodes/${id}/test`, { method: 'POST' }),
|
||||||
|
testBuildNodeDisk: (id: any) => api<any>(`/api/build-nodes/${id}/disk-test`, { method: 'POST' }),
|
||||||
// 工具
|
// 工具
|
||||||
listTools: () => api<any[]>('/api/tools'),
|
listTools: () => api<any[]>('/api/tools'),
|
||||||
updateTool: (id: any, b: any) => api(`/api/tools/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
updateTool: (id: any, b: any) => api(`/api/tools/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||||
@@ -153,4 +174,29 @@ export const apiClient = {
|
|||||||
batchParse: (b: { text: string; refType?: string; fixedRef?: string }) =>
|
batchParse: (b: { text: string; refType?: string; fixedRef?: string }) =>
|
||||||
api<any>('/api/batch/parse', { method: 'POST', body: JSON.stringify(b) }),
|
api<any>('/api/batch/parse', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
testLLM: () => api<any>('/api/settings/test-llm', { method: 'POST' }),
|
testLLM: () => api<any>('/api/settings/test-llm', { method: 'POST' }),
|
||||||
|
testLLMDraft: (body: any) => api<any>('/api/settings/test-llm', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
testLark: () => api<any>('/api/settings/test-lark', { method: 'POST' }),
|
||||||
|
// 用户与会话
|
||||||
|
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) }),
|
||||||
|
deleteUser: (id: any) => api(`/api/users/${id}`, { method: 'DELETE' }),
|
||||||
|
resetUserPassword: (id: any) => api<any>(`/api/users/${id}/reset-password`, { method: 'POST' }),
|
||||||
|
ldapListUsers: () => api<any[]>('/api/ldap/users', { method: 'POST' }),
|
||||||
|
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' }),
|
||||||
}
|
}
|
||||||
@@ -299,7 +299,7 @@ export default function BatchRelease({ onViewRecords }: { onViewRecords?: () =>
|
|||||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{p.name}</div>
|
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{p.name}</div>
|
||||||
<div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-secondary)' }}><GitBranch className="w-3 h-3" /> {p.branch}</div>
|
<div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-secondary)' }}><GitBranch className="w-3 h-3" /> {p.branch}</div>
|
||||||
<div><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
|
<div><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
|
||||||
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>{p.deployType === 'k8s' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> K8s</span> : p.deployType === 'gitops' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> GitOps</span> : <span className="flex items-center gap-1"><Server className="w-3 h-3" /> 主机</span>}</div>
|
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>{p.deployType === 'k8s' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> Kubernetes</span> : p.deployType === 'gitops' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> GitOps</span> : <span className="flex items-center gap-1"><Server className="w-3 h-3" /> 主机</span>}</div>
|
||||||
<div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{p.env}</div>
|
<div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{p.env}</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className={`w-2 h-2 rounded-full ${p.lastStatus === 'running' ? 'animate-pulse' : ''}`} style={{ background: last.color }} />
|
<span className={`w-2 h-2 rounded-full ${p.lastStatus === 'running' ? 'animate-pulse' : ''}`} style={{ background: last.color }} />
|
||||||
@@ -337,7 +337,7 @@ export default function BatchRelease({ onViewRecords }: { onViewRecords?: () =>
|
|||||||
<option value="skip">标记失败并跳过</option>
|
<option value="skip">标记失败并跳过</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
</div>
|
</div>
|
||||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>K8s 就绪检查</label>
|
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>Kubernetes 就绪检查</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkDeployment} onChange={(e) => setCheckDeployment(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Deployment 副本就绪</span></label>
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkDeployment} onChange={(e) => setCheckDeployment(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Deployment 副本就绪</span></label>
|
||||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkService} onChange={(e) => setCheckService(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Service 可用性</span></label>
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkService} onChange={(e) => setCheckService(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Service 可用性</span></label>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Tag,
|
Tag,
|
||||||
Zap,
|
Zap,
|
||||||
|
HardDrive,
|
||||||
|
Gauge,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -60,9 +62,10 @@ interface NodeForm {
|
|||||||
labels: string;
|
labels: string;
|
||||||
maxConcurrency: string;
|
maxConcurrency: string;
|
||||||
workspacePath: string;
|
workspacePath: string;
|
||||||
|
diskInfo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: NodeForm = { name: '', ip: '', labels: '', maxConcurrency: '2', workspacePath: '/var/lib/flowstack/workspace' };
|
const emptyForm: NodeForm = { name: '', ip: '', labels: '', maxConcurrency: '2', workspacePath: '/var/lib/flowstack/workspace', diskInfo: '' };
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle = {
|
||||||
background: 'var(--panel-bg-secondary)',
|
background: 'var(--panel-bg-secondary)',
|
||||||
@@ -75,6 +78,9 @@ export default function BuildNodeManager() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
const nodesQuery = useQuery({ queryKey: ['build-nodes'], queryFn: () => apiClient.listBuildNodes() });
|
const nodesQuery = useQuery({ queryKey: ['build-nodes'], queryFn: () => apiClient.listBuildNodes() });
|
||||||
|
// SSH 凭证(磁盘性能测试等远程操作用)
|
||||||
|
const credentialsQuery = useQuery({ queryKey: ['credentials', 'ssh'], queryFn: () => apiClient.listCredentials('ssh') });
|
||||||
|
const sshCredentials: any[] = credentialsQuery.data ?? [];
|
||||||
const nodes: any[] = nodesQuery.data ?? [];
|
const nodes: any[] = nodesQuery.data ?? [];
|
||||||
|
|
||||||
// ---- 弹窗 ----
|
// ---- 弹窗 ----
|
||||||
@@ -92,6 +98,7 @@ export default function BuildNodeManager() {
|
|||||||
labels: parseJsonArray(node.labels).join(','),
|
labels: parseJsonArray(node.labels).join(','),
|
||||||
maxConcurrency: String(node.maxConcurrency ?? 2),
|
maxConcurrency: String(node.maxConcurrency ?? 2),
|
||||||
workspacePath: node.workspacePath ?? '',
|
workspacePath: node.workspacePath ?? '',
|
||||||
|
diskInfo: node.diskInfo ?? '',
|
||||||
}
|
}
|
||||||
: emptyForm,
|
: emptyForm,
|
||||||
);
|
);
|
||||||
@@ -130,6 +137,17 @@ export default function BuildNodeManager() {
|
|||||||
onError: (err: Error) => toast.error(err.message),
|
onError: (err: Error) => toast.error(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 磁盘性能测试(dd 实测)
|
||||||
|
const diskMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.testBuildNodeDisk(id),
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
if (result?.success) toast.success('磁盘性能:' + result.message, { duration: 6000 });
|
||||||
|
else toast.error(result?.message || '磁盘测试失败');
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('磁盘测试失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: (vars: { id: any; enabled: boolean }) => apiClient.updateBuildNode(vars.id, { enabled: vars.enabled }),
|
mutationFn: (vars: { id: any; enabled: boolean }) => apiClient.updateBuildNode(vars.id, { enabled: vars.enabled }),
|
||||||
onSuccess: (_data, vars) => {
|
onSuccess: (_data, vars) => {
|
||||||
@@ -149,9 +167,11 @@ export default function BuildNodeManager() {
|
|||||||
body: {
|
body: {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
ip: form.ip.trim(),
|
ip: form.ip.trim(),
|
||||||
|
credentialId: editing?.credentialId ?? null,
|
||||||
labels: JSON.stringify(splitComma(form.labels)),
|
labels: JSON.stringify(splitComma(form.labels)),
|
||||||
maxConcurrency: Number(form.maxConcurrency) || 1,
|
maxConcurrency: Number(form.maxConcurrency) || 1,
|
||||||
workspacePath: form.workspacePath.trim(),
|
workspacePath: form.workspacePath.trim(),
|
||||||
|
diskInfo: form.diskInfo.trim(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -248,6 +268,18 @@ export default function BuildNodeManager() {
|
|||||||
onCheckedChange={(checked) => toggleMutation.mutate({ id: node.id, enabled: checked })}
|
onCheckedChange={(checked) => toggleMutation.mutate({ id: node.id, enabled: checked })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="磁盘性能测试(dd 读写 256MB)"
|
||||||
|
disabled={diskMutation.isPending && diskMutation.variables === node.id}
|
||||||
|
onClick={() => diskMutation.mutate(node.id)}
|
||||||
|
>
|
||||||
|
{diskMutation.isPending && diskMutation.variables === node.id ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--accent-blue)' }} />
|
||||||
|
) : (
|
||||||
|
<Gauge className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded hover:bg-black/5"
|
className="p-1 rounded hover:bg-black/5"
|
||||||
title="测试连接"
|
title="测试连接"
|
||||||
@@ -292,10 +324,25 @@ export default function BuildNodeManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info: 工作空间 / 心跳(后端暂无 CPU/内存/磁盘数据) */}
|
{/* Info: 磁盘(含性能) / 工作空间 / 心跳 */}
|
||||||
<div className="grid grid-cols-2 gap-3 text-[11px]">
|
<div className="grid grid-cols-3 gap-3 text-[11px]">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<span className="flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>工作空间</span>
|
<HardDrive className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
<span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }} title={node.diskInfo ? `磁盘 ${node.diskInfo}` : '磁盘未上报'}>
|
||||||
|
{node.diskInfo || '—'}
|
||||||
|
</span>
|
||||||
|
{node.diskWriteMBps > 0 && (
|
||||||
|
<span
|
||||||
|
className="font-mono"
|
||||||
|
style={{ color: node.diskWriteMBps > 200 ? '#1a7f37' : node.diskWriteMBps > 50 ? '#9a6700' : '#cf222e' }}
|
||||||
|
title={`磁盘性能(${node.diskTestMode || ''} ${node.diskTestedAt ? new Date(node.diskTestedAt).toLocaleString('zh-CN', { hour12: false }) : ''})`}
|
||||||
|
>
|
||||||
|
↑{Math.round(node.diskWriteMBps)}M ↓{Math.round(node.diskReadMBps)}M
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>空间</span>
|
||||||
<span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }} title={node.workspacePath}>
|
<span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }} title={node.workspacePath}>
|
||||||
{node.workspacePath || '—'}
|
{node.workspacePath || '—'}
|
||||||
</span>
|
</span>
|
||||||
@@ -366,6 +413,24 @@ export default function BuildNodeManager() {
|
|||||||
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.workspacePath} onChange={(e) => setForm({ ...form, workspacePath: e.target.value })} placeholder="/var/lib/flowstack/workspace" />
|
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.workspacePath} onChange={(e) => setForm({ ...form, workspacePath: e.target.value })} placeholder="/var/lib/flowstack/workspace" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>磁盘用量</Label>
|
||||||
|
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.diskInfo} onChange={(e) => setForm({ ...form, diskInfo: e.target.value })} placeholder="如 45G/200G(已用/总量)" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>SSH 凭证(磁盘性能测试等远程操作)</Label>
|
||||||
|
<select
|
||||||
|
className="w-full h-8 px-2 text-[12px] rounded-md border outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
value={editing?.credentialId ? String(editing.credentialId) : ''}
|
||||||
|
onChange={(e) => setEditing((prev: any) => ({ ...(prev || {}), credentialId: e.target.value ? Number(e.target.value) : null }))}
|
||||||
|
>
|
||||||
|
<option value="">不关联(本机节点无需凭证)</option>
|
||||||
|
{sshCredentials.map((cred: any) => (
|
||||||
|
<option key={cred.id} value={cred.id}>{cred.name}{cred.summary ? ` · ${cred.summary}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Trash2,
|
Trash2,
|
||||||
Edit2,
|
Edit2,
|
||||||
Shield,
|
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Container,
|
Container,
|
||||||
Cloud,
|
Boxes,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
KeyRound,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -22,14 +22,15 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
const typeConfig: Record<string, { icon: typeof Key; label: string; color: string; bg: string }> = {
|
const typeConfig: Record<string, { icon: typeof Key; label: string; color: string; bg: string }> = {
|
||||||
ssh: { icon: Key, label: 'SSH密钥', color: '#0969da', bg: '#ddf4ff' },
|
ssh: { icon: Key, label: 'SSH 密钥', color: '#0969da', bg: '#ddf4ff' },
|
||||||
password: { icon: Shield, label: '用户名密码', color: '#9a6700', bg: '#fff8c5' },
|
docker: { icon: Container, label: 'Docker 认证', color: '#1a7f37', bg: '#dafbe1' },
|
||||||
docker: { icon: Container, label: 'Docker认证', color: '#1a7f37', bg: '#dafbe1' },
|
git: { icon: GitBranch, label: 'Git 仓库', color: '#8250df', bg: '#fbefff' },
|
||||||
git: { icon: GitBranch, label: 'Git凭证', color: '#8250df', bg: '#fbefff' },
|
kubernetes: { icon: Boxes, label: 'Kubernetes', color: '#bc4c00', bg: '#fff1e5' },
|
||||||
kubeconfig: { icon: Key, label: 'Kubeconfig', color: '#bc4c00', bg: '#fff1e5' },
|
|
||||||
aksk: { icon: Cloud, label: 'AK/SK', color: '#0f766e', bg: '#ccfbf1' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 新建可选类型(发布流程所需:ssh / docker / git / kubernetes)
|
||||||
|
const CREATE_TYPES = ['ssh', 'docker', 'git', 'kubernetes'];
|
||||||
|
|
||||||
const fallbackConfig = { icon: Key, label: '其他', color: '#6e7781', bg: '#f6f8fa' };
|
const fallbackConfig = { icon: Key, label: '其他', color: '#6e7781', bg: '#f6f8fa' };
|
||||||
|
|
||||||
function getConfig(type: string) {
|
function getConfig(type: string) {
|
||||||
@@ -45,10 +46,133 @@ interface CredentialForm {
|
|||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
content: string;
|
// ssh
|
||||||
|
privateKey: string; sshUser: string; sshPort: string; sshPassword: string;
|
||||||
|
// docker
|
||||||
|
dockerServer: string; dockerUsername: string; dockerPassword: string;
|
||||||
|
// git
|
||||||
|
gitUrl: string; gitUsername: string; gitToken: string;
|
||||||
|
// kubernetes
|
||||||
|
k8sMode: 'token' | 'kubeconfig';
|
||||||
|
k8sApiServer: string; k8sNamespace: string; k8sToken: string;
|
||||||
|
k8sKubeconfig: string; k8sContext: string;
|
||||||
|
rbacOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: CredentialForm = { name: '', type: 'ssh', scope: '全局', content: '' };
|
const emptyForm: CredentialForm = {
|
||||||
|
name: '', type: 'ssh', scope: '全局',
|
||||||
|
privateKey: '', sshUser: 'root', sshPort: '22', sshPassword: '',
|
||||||
|
dockerServer: '', dockerUsername: '', dockerPassword: '',
|
||||||
|
gitUrl: '', gitUsername: '', gitToken: '',
|
||||||
|
k8sMode: 'token', k8sApiServer: '', k8sNamespace: 'flowstack', k8sToken: '',
|
||||||
|
k8sKubeconfig: '', k8sContext: '',
|
||||||
|
rbacOpen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按类型组装加密内容 JSON(编辑时仅提交非空字段 = 留空不修改)
|
||||||
|
function buildContent(form: CredentialForm): string | undefined {
|
||||||
|
const has = (s: string) => s && s.trim() !== '';
|
||||||
|
let obj: Record<string, any>;
|
||||||
|
switch (form.type) {
|
||||||
|
case 'ssh':
|
||||||
|
obj = {};
|
||||||
|
if (has(form.privateKey)) obj.private_key = form.privateKey.trim();
|
||||||
|
if (has(form.sshUser)) obj.ssh_user = form.sshUser.trim();
|
||||||
|
if (has(form.sshPort)) obj.ssh_port = Number(form.sshPort) || 22;
|
||||||
|
if (has(form.sshPassword)) obj.password = form.sshPassword;
|
||||||
|
break;
|
||||||
|
case 'docker':
|
||||||
|
obj = {};
|
||||||
|
if (has(form.dockerServer)) obj.server = form.dockerServer.trim();
|
||||||
|
if (has(form.dockerUsername)) obj.username = form.dockerUsername.trim();
|
||||||
|
if (has(form.dockerPassword)) obj.password = form.dockerPassword;
|
||||||
|
break;
|
||||||
|
case 'git':
|
||||||
|
obj = {};
|
||||||
|
if (has(form.gitUrl)) obj.url = form.gitUrl.trim();
|
||||||
|
if (has(form.gitUsername)) obj.username = form.gitUsername.trim();
|
||||||
|
if (has(form.gitToken)) obj.token = form.gitToken;
|
||||||
|
break;
|
||||||
|
case 'kubernetes':
|
||||||
|
obj = { mode: form.k8sMode };
|
||||||
|
if (form.k8sMode === 'token') {
|
||||||
|
if (has(form.k8sApiServer)) obj.apiServer = form.k8sApiServer.trim();
|
||||||
|
if (has(form.k8sNamespace)) obj.namespace = form.k8sNamespace.trim();
|
||||||
|
if (has(form.k8sToken)) obj.token = form.k8sToken.trim();
|
||||||
|
} else {
|
||||||
|
if (has(form.k8sKubeconfig)) obj.kubeconfig = form.k8sKubeconfig.trim();
|
||||||
|
if (has(form.k8sContext)) obj.context = form.k8sContext.trim();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const values = Object.values(obj).filter((v) => v !== undefined && v !== '' && v !== 'mode');
|
||||||
|
if (values.length === 0) return undefined;
|
||||||
|
return JSON.stringify(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命名空间最小授权命令(参考 LokiStack:SA + 命名空间级 Role + 长期 Token)
|
||||||
|
function buildRbacCommand(ns: string): string {
|
||||||
|
const safeNs = (ns && ns.trim()) || 'flowstack';
|
||||||
|
return [
|
||||||
|
"# 1) 一次创建:命名空间 + ServiceAccount + 命名空间级 Role + 长期 Token",
|
||||||
|
"kubectl apply -f - <<'YAML'",
|
||||||
|
"apiVersion: v1",
|
||||||
|
"kind: Namespace",
|
||||||
|
"metadata:",
|
||||||
|
" name: " + safeNs,
|
||||||
|
"---",
|
||||||
|
"apiVersion: v1",
|
||||||
|
"kind: ServiceAccount",
|
||||||
|
"metadata:",
|
||||||
|
" name: flowstack",
|
||||||
|
" namespace: " + safeNs,
|
||||||
|
"---",
|
||||||
|
"apiVersion: rbac.authorization.k8s.io/v1",
|
||||||
|
"kind: Role",
|
||||||
|
"metadata:",
|
||||||
|
" name: flowstack",
|
||||||
|
" namespace: " + safeNs,
|
||||||
|
"rules:",
|
||||||
|
' - apiGroups: [""]',
|
||||||
|
' resources: ["pods", "pods/log", "services", "configmaps", "secrets", "events"]',
|
||||||
|
' verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]',
|
||||||
|
' - apiGroups: ["apps"]',
|
||||||
|
' resources: ["deployments", "statefulsets", "daemonsets"]',
|
||||||
|
' verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]',
|
||||||
|
' - apiGroups: ["networking.k8s.io"]',
|
||||||
|
' resources: ["ingresses"]',
|
||||||
|
' verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]',
|
||||||
|
"---",
|
||||||
|
"apiVersion: rbac.authorization.k8s.io/v1",
|
||||||
|
"kind: RoleBinding",
|
||||||
|
"metadata:",
|
||||||
|
" name: flowstack",
|
||||||
|
" namespace: " + safeNs,
|
||||||
|
"subjects:",
|
||||||
|
" - kind: ServiceAccount",
|
||||||
|
" name: flowstack",
|
||||||
|
" namespace: " + safeNs,
|
||||||
|
"roleRef:",
|
||||||
|
" kind: Role",
|
||||||
|
" name: flowstack",
|
||||||
|
" apiGroup: rbac.authorization.k8s.io",
|
||||||
|
"---",
|
||||||
|
"apiVersion: v1",
|
||||||
|
"kind: Secret",
|
||||||
|
"metadata:",
|
||||||
|
" name: flowstack-token",
|
||||||
|
" namespace: " + safeNs,
|
||||||
|
" annotations:",
|
||||||
|
" kubernetes.io/service-account.name: flowstack",
|
||||||
|
"type: kubernetes.io/service-account-token",
|
||||||
|
"YAML",
|
||||||
|
"",
|
||||||
|
"# 2) 取出长期 Token(不自动过期),粘贴到上方 Token 输入框",
|
||||||
|
"kubectl -n " + safeNs + " get secret flowstack-token -o jsonpath='{.data.token}' | base64 -d",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle = {
|
||||||
background: 'var(--panel-bg-secondary)',
|
background: 'var(--panel-bg-secondary)',
|
||||||
@@ -81,8 +205,8 @@ export default function CredentialManager() {
|
|||||||
setEditing(cred ?? null);
|
setEditing(cred ?? null);
|
||||||
setForm(
|
setForm(
|
||||||
cred
|
cred
|
||||||
? { name: cred.name ?? '', type: cred.type ?? 'ssh', scope: cred.scope ?? '', content: '' }
|
? { ...emptyForm, name: cred.name ?? '', type: cred.type ?? 'ssh', scope: cred.scope ?? '' }
|
||||||
: emptyForm,
|
: { ...emptyForm },
|
||||||
);
|
);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
@@ -114,9 +238,14 @@ export default function CredentialManager() {
|
|||||||
toast.error('请填写凭证名称');
|
toast.error('请填写凭证名称');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 内容加密存储(write-only):编辑时留空表示不修改
|
// 内容加密存储(write-only):编辑时字段留空表示不修改
|
||||||
|
const content = buildContent(form);
|
||||||
|
if (!editing && !content) {
|
||||||
|
toast.error('请填写凭证内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const body: any = { name: form.name.trim(), type: form.type, scope: form.scope.trim() };
|
const body: any = { name: form.name.trim(), type: form.type, scope: form.scope.trim() };
|
||||||
if (form.content.trim()) body.content = form.content.trim();
|
if (content) body.content = content;
|
||||||
saveMutation.mutate({ id: editing?.id, body });
|
saveMutation.mutate({ id: editing?.id, body });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -338,14 +467,14 @@ export default function CredentialManager() {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>类型</Label>
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>类型</Label>
|
||||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })} disabled={!!editing}>
|
||||||
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
|
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
|
||||||
<SelectValue placeholder="选择类型" />
|
<SelectValue placeholder="选择类型" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.entries(typeConfig).map(([type, config]) => (
|
{CREATE_TYPES.map((type) => (
|
||||||
<SelectItem key={type} value={type} className="text-[12px]">
|
<SelectItem key={type} value={type} className="text-[12px]">
|
||||||
{config.label}
|
{typeConfig[type].label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -362,18 +491,157 @@ export default function CredentialManager() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* ---- 类型化字段(编辑时留空 = 不修改) ---- */}
|
||||||
|
{form.type === 'ssh' && (
|
||||||
|
<>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>内容</Label>
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>私钥(PEM)</Label>
|
||||||
<Textarea
|
<Textarea className="text-[11px] min-h-[72px] font-mono" style={inputStyle}
|
||||||
className="text-[12px] min-h-[88px] font-mono"
|
value={form.privateKey} onChange={(e) => setForm({ ...form, privateKey: e.target.value })}
|
||||||
style={inputStyle}
|
placeholder={editing ? '留空表示不修改' : '-----BEGIN OPENSSH PRIVATE KEY-----\n...'} />
|
||||||
value={form.content}
|
|
||||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
|
||||||
placeholder={editing ? '留空表示不修改' : '私钥 / 密码 / Token / kubeconfig / AK·SK 等'}
|
|
||||||
/>
|
|
||||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
内容加密存储,保存后不可查看。
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<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.sshUser}
|
||||||
|
onChange={(e) => setForm({ ...form, sshUser: e.target.value })} placeholder="root" />
|
||||||
|
</div>
|
||||||
|
<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.sshPort}
|
||||||
|
onChange={(e) => setForm({ ...form, sshPort: e.target.value })} placeholder="22" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码(可选)</Label>
|
||||||
|
<Input type="password" className="h-8 text-[12px]" style={inputStyle} value={form.sshPassword}
|
||||||
|
onChange={(e) => setForm({ ...form, sshPassword: e.target.value })} placeholder={editing ? '留空不修改' : '密钥密码'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{form.type === 'docker' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Registry 地址</Label>
|
||||||
|
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.dockerServer}
|
||||||
|
onChange={(e) => setForm({ ...form, dockerServer: e.target.value })} placeholder="registry.example.com" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<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.dockerUsername}
|
||||||
|
onChange={(e) => setForm({ ...form, dockerUsername: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码 / Token</Label>
|
||||||
|
<Input type="password" className="h-8 text-[12px]" style={inputStyle} value={form.dockerPassword}
|
||||||
|
onChange={(e) => setForm({ ...form, dockerPassword: e.target.value })} placeholder={editing ? '留空不修改' : ''} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{form.type === 'git' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>仓库地址</Label>
|
||||||
|
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.gitUrl}
|
||||||
|
onChange={(e) => setForm({ ...form, gitUrl: e.target.value })} placeholder="https://gitee.com/org/repo.git" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<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.gitUsername}
|
||||||
|
onChange={(e) => setForm({ ...form, gitUsername: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>访问 Token</Label>
|
||||||
|
<Input type="password" className="h-8 text-[12px]" style={inputStyle} value={form.gitToken}
|
||||||
|
onChange={(e) => setForm({ ...form, gitToken: e.target.value })} placeholder={editing ? '留空不修改' : 'Personal Access Token'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{form.type === 'kubernetes' && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center rounded-md border overflow-hidden w-fit" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
{[{ id: 'token', label: 'Token(推荐)' }, { id: 'kubeconfig', label: 'Kubeconfig' }].map((m) => (
|
||||||
|
<button key={m.id} type="button" onClick={() => setForm({ ...form, k8sMode: m.id as 'token' | 'kubeconfig' })}
|
||||||
|
className="px-3 py-[5px] text-[11px] font-medium"
|
||||||
|
style={{ background: form.k8sMode === m.id ? 'var(--accent-blue)' : 'transparent', color: form.k8sMode === m.id ? '#fff' : 'var(--text-secondary)' }}>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{form.k8sMode === 'token' ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Server</Label>
|
||||||
|
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.k8sApiServer}
|
||||||
|
onChange={(e) => setForm({ ...form, k8sApiServer: e.target.value })} placeholder="https://k8s.example.com:6443" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>命名空间</Label>
|
||||||
|
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.k8sNamespace}
|
||||||
|
onChange={(e) => setForm({ ...form, k8sNamespace: e.target.value })} placeholder="flowstack" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Token{editing ? '(留空不修改)' : ''}</Label>
|
||||||
|
<Textarea className="text-[11px] min-h-[64px] font-mono" style={inputStyle}
|
||||||
|
value={form.k8sToken} onChange={(e) => setForm({ ...form, k8sToken: e.target.value })}
|
||||||
|
placeholder="eyJhbGciOiJSUzI1NiIs..." />
|
||||||
|
</div>
|
||||||
|
{/* 命名空间长期授权引导(LokiStack 模式) */}
|
||||||
|
<div className="rounded-md border" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)' }}>
|
||||||
|
<button type="button" onClick={() => setForm({ ...form, rbacOpen: !form.rbacOpen })}
|
||||||
|
className="w-full flex items-center gap-1.5 px-3 py-2 text-[11px] font-medium"
|
||||||
|
style={{ color: 'var(--accent-blue)' }}>
|
||||||
|
<KeyRound className="w-3 h-3" />
|
||||||
|
最小授权命令:只授予「{form.k8sNamespace || 'flowstack'}」命名空间权限(点击展开)
|
||||||
|
</button>
|
||||||
|
{form.rbacOpen && (
|
||||||
|
<div className="px-3 pb-3 space-y-2">
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
在集群执行一次以下命令创建 ServiceAccount + 命名空间级 Role + 长期 Token;Token 泄露只影响该命名空间,不要使用 cluster-admin。
|
||||||
|
</p>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="text-[10px] font-mono whitespace-pre overflow-x-auto p-2.5 rounded-md max-h-64 overflow-y-auto custom-scrollbar"
|
||||||
|
style={{ background: 'var(--panel-bg)', border: '1px solid var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
{buildRbacCommand(form.k8sNamespace)}
|
||||||
|
</pre>
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => { navigator.clipboard.writeText(buildRbacCommand(form.k8sNamespace)); toast.success('授权命令已复制'); }}
|
||||||
|
className="absolute top-1.5 right-1.5 p-1 rounded" style={{ color: 'var(--text-tertiary)', background: 'var(--panel-bg-secondary)' }} title="复制">
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Token 长期有效且仅绑定该命名空间 Role;内容加密存储、接口不回明文。轮换:删除 Secret 重建即失效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Kubeconfig(整份 YAML{editing ? ',留空不修改' : ''})</Label>
|
||||||
|
<Textarea className="text-[11px] min-h-[110px] font-mono" style={inputStyle}
|
||||||
|
value={form.k8sKubeconfig} onChange={(e) => setForm({ ...form, k8sKubeconfig: e.target.value })}
|
||||||
|
placeholder={'apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://...'} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Context(可选,覆盖默认)</Label>
|
||||||
|
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.k8sContext}
|
||||||
|
onChange={(e) => setForm({ ...form, k8sContext: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
内容加密存储,保存后不可查看;编辑时留空表示保持原值。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { BookOpen, RefreshCw, Loader2 } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Marked } from 'marked';
|
||||||
|
import { gfmHeadingId } from 'marked-gfm-heading-id';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
type DocKind = 'guide' | 'api';
|
||||||
|
|
||||||
|
const meta: Record<DocKind, { title: string; subtitle: string }> = {
|
||||||
|
guide: { title: '使用指南', subtitle: '每个功能一句话讲清:是什么 → 怎么用 → 实用技巧' },
|
||||||
|
api: { title: '接口文档', subtitle: '与当前后端代码对齐;调用需登录 Token,敏感字段只写不回显' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从 Markdown 提取 ## 级标题做目录(marked-gfm-heading-id 生成锚点 id)
|
||||||
|
function extractToc(md: string): { text: string; id: string; level: number }[] {
|
||||||
|
const out: { text: string; id: string; level: number }[] = [];
|
||||||
|
for (const line of md.split('\n')) {
|
||||||
|
const m = /^(#{2,3})\s+(.+)$/.exec(line.trim());
|
||||||
|
if (!m) continue;
|
||||||
|
const text = m[2].trim();
|
||||||
|
out.push({ text, id: headingId(text), level: m[1].length });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与 marked-gfm-heading-id 的默认 slug 规则保持一致(小写、去标点、空格转 -)
|
||||||
|
function headingId(text: string): string {
|
||||||
|
return text.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, '').trim().replace(/\s+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Docs() {
|
||||||
|
const [kind, setKind] = useState<DocKind>('guide');
|
||||||
|
|
||||||
|
const { data, isLoading, refetch, isRefetching } = useQuery({
|
||||||
|
queryKey: ['docs'],
|
||||||
|
queryFn: () => api<any>('/api/docs'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = (kind === 'guide' ? data?.guide : data?.api) || '';
|
||||||
|
|
||||||
|
const html = useMemo(() => {
|
||||||
|
const marked = new Marked(gfmHeadingId());
|
||||||
|
return marked.parse(raw, { async: false }) as string;
|
||||||
|
}, [raw]);
|
||||||
|
|
||||||
|
const toc = useMemo(() => extractToc(raw), [raw]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<BookOpen className="w-4.5 h-4.5" style={{ color: 'var(--accent-blue)' }} />
|
||||||
|
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>文档中心</h1>
|
||||||
|
{/* 指南/接口 切换 */}
|
||||||
|
<div className="flex items-center rounded-md border overflow-hidden ml-2" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
{(['guide', 'api'] as DocKind[]).map((k) => (
|
||||||
|
<button key={k} onClick={() => setKind(k)} className="px-3 py-[3px] text-[11px] font-medium"
|
||||||
|
style={{ background: kind === k ? 'var(--accent-blue)' : 'transparent', color: kind === k ? '#fff' : 'var(--text-secondary)' }}>
|
||||||
|
{meta[k].title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => refetch()} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${isRefetching ? 'animate-spin' : ''}`} />刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* 左侧目录 */}
|
||||||
|
<div className="w-56 border-r overflow-y-auto custom-scrollbar flex-shrink-0 py-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="px-4 pb-2 text-[11px] font-semibold" style={{ color: 'var(--text-tertiary)' }}>目录</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="px-4 py-4 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>
|
||||||
|
) : (
|
||||||
|
toc.map((t) => (
|
||||||
|
<a key={t.id + t.text} href={`#${t.id}`}
|
||||||
|
className={`block truncate hover:bg-black/[0.03] ${t.level === 3 ? 'pl-7' : 'pl-4'} pr-3 py-[5px] text-[12px]`}
|
||||||
|
style={{ color: t.level === 3 ? 'var(--text-tertiary)' : 'var(--text-secondary)' }}>
|
||||||
|
{t.text}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧内容 */}
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-20 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />加载文档中…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-3xl mx-auto px-8 py-6">
|
||||||
|
<div className="mb-4 pb-4 border-b" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<h2 className="text-[17px] font-bold" style={{ color: 'var(--text-primary)' }}>{meta[kind].title}</h2>
|
||||||
|
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>{meta[kind].subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -272,7 +272,7 @@ export default function EnvironmentManager() {
|
|||||||
color: activeTab === 'k8s' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
color: activeTab === 'k8s' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
K8s集群
|
Kubernetes 集群
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,7 +433,7 @@ export default function EnvironmentManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* K8s Clusters */
|
/* Kubernetes 集群 */
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||||
{clusterStats.map((stat) => (
|
{clusterStats.map((stat) => (
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
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 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) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
toast.error('请输入用户名和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
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) {
|
||||||
|
toast.error('登录失败', { description: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex items-center justify-center" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
|
<div className="w-[380px] rounded-xl border shadow-lg p-8" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex flex-col items-center mb-6">
|
||||||
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-3" style={{ background: 'linear-gradient(135deg, #0969da, #8250df)' }}>
|
||||||
|
<Rocket className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[18px] font-bold" style={{ color: 'var(--text-primary)' }}>FlowStack</h1>
|
||||||
|
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>发布系统 · 请登录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-[13px] rounded-md border outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码</label>
|
||||||
|
<div className="relative">
|
||||||
|
<KeyRound className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-[13px] rounded-md border outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="初始密码见 data/initial-admin-password.txt"
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
className="w-full py-2.5 text-[13px] font-medium rounded-md text-white disabled:opacity-60 flex items-center justify-center gap-2"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
登 录
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-[11px] text-center mt-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
支持本地账号与 LDAP 账号(启用后自动识别)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api';
|
import { apiClient } from '@/lib/api';
|
||||||
|
import { useSession } from '@/providers/session';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; l
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deployTypeConfig: Record<string, { label: string; color: string; bg: string }> = {
|
const deployTypeConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
k8s: { label: 'K8s', color: '#0969da', bg: '#ddf4ff' },
|
k8s: { label: 'Kubernetes', color: '#0969da', bg: '#ddf4ff' },
|
||||||
host: { label: '主机', color: '#1a7f37', bg: '#dafbe1' },
|
host: { label: '主机', color: '#1a7f37', bg: '#dafbe1' },
|
||||||
gitops: { label: 'GitOps', color: '#0e7c7b', bg: '#d5f5f6' },
|
gitops: { label: 'GitOps', color: '#0e7c7b', bg: '#d5f5f6' },
|
||||||
};
|
};
|
||||||
@@ -120,6 +121,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState<CreatePipelineForm>(emptyForm);
|
const [form, setForm] = useState<CreatePipelineForm>(emptyForm);
|
||||||
|
const { isAdmin } = useSession();
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -163,6 +165,8 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
description: p.description || '',
|
description: p.description || '',
|
||||||
branch: p.sourceBranch || '-',
|
branch: p.sourceBranch || '-',
|
||||||
type: p.type,
|
type: p.type,
|
||||||
|
// 后端附带的当前用户权限等级:1 查看 / 2 执行 / 3 管理 / 4 管理员
|
||||||
|
accessLevel: (p as any).accessLevel ?? 4,
|
||||||
deploy: deployTypeConfig[p.deployType] || fallbackDeploy,
|
deploy: deployTypeConfig[p.deployType] || fallbackDeploy,
|
||||||
// 筛选用映射状态:lastRun 为空记为 pending(未执行)
|
// 筛选用映射状态:lastRun 为空记为 pending(未执行)
|
||||||
mappedStatus: p.lastRun?.status ?? 'pending',
|
mappedStatus: p.lastRun?.status ?? 'pending',
|
||||||
@@ -289,7 +293,8 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
<List className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
<List className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* New pipeline button */}
|
{/* New pipeline button(新建/删除仅管理员) */}
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
@@ -298,6 +303,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
新建流水线
|
新建流水线
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -314,7 +320,11 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
|
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p>
|
<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>
|
</div>
|
||||||
) : viewMode === 'card' ? (
|
) : viewMode === 'card' ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
@@ -397,6 +407,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||||
|
{pipeline.accessLevel >= 2 && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -407,9 +418,10 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
>
|
>
|
||||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded hover:bg-black/5"
|
className="p-1 rounded hover:bg-black/5"
|
||||||
title="配置"
|
title={pipeline.accessLevel >= 3 ? '配置' : '查看'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelectPipeline(pipeline.id);
|
onSelectPipeline(pipeline.id);
|
||||||
@@ -417,6 +429,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
>
|
>
|
||||||
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded hover:bg-black/5"
|
className="p-1 rounded hover:bg-black/5"
|
||||||
title="删除"
|
title="删除"
|
||||||
@@ -427,6 +440,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,6 +520,7 @@ 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 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerText}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-0">
|
<div className="flex items-center gap-0">
|
||||||
|
{pipeline.accessLevel >= 2 && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -516,9 +531,10 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
>
|
>
|
||||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded hover:bg-black/5"
|
className="p-1 rounded hover:bg-black/5"
|
||||||
title="配置"
|
title={pipeline.accessLevel >= 3 ? '配置' : '查看'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelectPipeline(pipeline.id);
|
onSelectPipeline(pipeline.id);
|
||||||
@@ -526,6 +542,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
>
|
>
|
||||||
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded hover:bg-black/5"
|
className="p-1 rounded hover:bg-black/5"
|
||||||
title="删除"
|
title="删除"
|
||||||
@@ -536,6 +553,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -571,7 +589,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
<input
|
<input
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
placeholder="例如 Go后端服务 - Docker构建 + K8s部署"
|
placeholder="例如 Go后端服务 - Docker构建 + Kubernetes 部署"
|
||||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
@@ -607,7 +625,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<SelectItem value="host">主机</SelectItem>
|
<SelectItem value="host">主机</SelectItem>
|
||||||
<SelectItem value="k8s">K8s</SelectItem>
|
<SelectItem value="k8s">Kubernetes</SelectItem>
|
||||||
<SelectItem value="gitops">GitOps</SelectItem>
|
<SelectItem value="gitops">GitOps</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Search, GitBranch,
|
Search, GitBranch,
|
||||||
CheckCircle2, XCircle, Clock, ChevronRight, Terminal,
|
CheckCircle2, XCircle, Clock, ChevronRight, Terminal,
|
||||||
RotateCcw, ExternalLink, Calendar, User, Container, Layers,
|
RotateCcw, ExternalLink, Calendar, User, Container, Layers,
|
||||||
Wifi, WifiOff, Loader2
|
Wifi, WifiOff, Loader2, Sparkles
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -54,6 +54,8 @@ interface RunRecord {
|
|||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
buildNumber?: number;
|
buildNumber?: number;
|
||||||
externalUrl?: string;
|
externalUrl?: string;
|
||||||
|
analysis?: string; // 失败时后端自动生成的 AI 失败分析
|
||||||
|
canRetry?: boolean; // 后端按当前用户权限附加:执行等级及以上才允许重试
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RunStage {
|
interface RunStage {
|
||||||
@@ -144,6 +146,8 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
|||||||
|
|
||||||
const logsToRender: RunLog[] = isRunning ? liveLogs : (detail?.logs ?? []);
|
const logsToRender: RunLog[] = isRunning ? liveLogs : (detail?.logs ?? []);
|
||||||
|
|
||||||
|
const analysis: string | undefined = detail?.run?.analysis ?? record.analysis;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t" style={{ borderColor: 'var(--border-color-light)' }}>
|
<div className="border-t" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
<div className="flex items-center gap-0 px-4 border-b" style={{ borderColor: 'var(--border-color-light)' }}>
|
<div className="flex items-center gap-0 px-4 border-b" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
@@ -165,6 +169,19 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
{/* AI 失败分析(日志区上方) */}
|
||||||
|
{analysis ? (
|
||||||
|
<div className="mb-4 rounded-lg border p-3" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
|
||||||
|
<div className="flex items-center gap-1.5 text-[12px] font-semibold mb-1.5" style={{ color: '#8250df' }}>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />AI 失败分析
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] leading-5 whitespace-pre-wrap" style={{ color: 'var(--text-primary)' }}>{analysis}</div>
|
||||||
|
</div>
|
||||||
|
) : isRunning ? (
|
||||||
|
<div className="mb-4 flex items-center gap-1.5 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Sparkles className="w-3 h-3" style={{ color: '#8250df' }} />失败后将自动生成 AI 分析
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{activeTab === 'stages' && (
|
{activeTab === 'stages' && (
|
||||||
detailLoading && stages.length === 0 ? (
|
detailLoading && stages.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
@@ -268,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)' }}>
|
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" />查看日志
|
<Terminal className="w-3 h-3" />查看日志
|
||||||
</button>
|
</button>
|
||||||
{(record.status === 'failed' || record.status === 'cancelled') && (
|
{(record.status === 'failed' || record.status === 'cancelled') && record.canRetry && (
|
||||||
<button
|
<button
|
||||||
onClick={() => retryMutation.mutate()}
|
onClick={() => retryMutation.mutate()}
|
||||||
disabled={retryMutation.isPending}
|
disabled={retryMutation.isPending}
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Palette, Monitor, Moon, Sun,
|
Palette, Monitor, Moon, Sun,
|
||||||
Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2
|
Save, RotateCcw, Globe, Bell, Loader2, ExternalLink, ChevronDown, ChevronRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api';
|
import { apiClient } from '@/lib/api';
|
||||||
|
import { useTheme, type ThemeMode } from '@/providers/theme';
|
||||||
|
import WebhookManager from '@/components/settings/WebhookManager';
|
||||||
|
|
||||||
interface ThemeConfig {
|
interface ThemeConfig {
|
||||||
mode: 'light' | 'dark' | 'system';
|
mode: 'light' | 'dark' | 'system';
|
||||||
primaryColor: string;
|
|
||||||
fontSize: 'small' | 'medium' | 'large';
|
|
||||||
density: 'compact' | 'comfortable' | 'spacious';
|
|
||||||
sidebarStyle: 'collapsed' | 'icons' | 'full';
|
|
||||||
borderRadius: 'sharp' | 'soft' | 'round';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationConfig {
|
interface NotificationConfig {
|
||||||
@@ -26,11 +23,6 @@ interface NotificationConfig {
|
|||||||
|
|
||||||
const defaultTheme: ThemeConfig = {
|
const defaultTheme: ThemeConfig = {
|
||||||
mode: 'light',
|
mode: 'light',
|
||||||
primaryColor: '#0969da',
|
|
||||||
fontSize: 'medium',
|
|
||||||
density: 'comfortable',
|
|
||||||
sidebarStyle: 'full',
|
|
||||||
borderRadius: 'soft',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultNotifications: NotificationConfig = {
|
const defaultNotifications: NotificationConfig = {
|
||||||
@@ -53,26 +45,63 @@ function safeParse<T extends object>(raw: unknown, fallback: T): T {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorOptions = [
|
// LLM 服务商预设(OpenAI 兼容接口)
|
||||||
{ name: '蓝色', value: '#0969da', class: 'bg-[#0969da]' },
|
const LLM_PROVIDERS = [
|
||||||
{ name: '绿色', value: '#1a7f37', class: 'bg-[#1a7f37]' },
|
{ id: 'deepseek', label: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', model: 'deepseek-chat', keyUrl: 'https://platform.deepseek.com/api_keys' },
|
||||||
{ name: '紫色', value: '#8250df', class: 'bg-[#8250df]' },
|
{ id: 'glm', label: '智谱 GLM', endpoint: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.7', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys' },
|
||||||
{ name: '橙色', value: '#bc4c00', class: 'bg-[#bc4c00]' },
|
{ id: 'glm-coding', label: '智谱 GLM 编码套餐', endpoint: 'https://open.bigmodel.cn/api/coding/paas/v4', model: 'glm-4.7', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys' },
|
||||||
{ name: '红色', value: '#cf222e', class: 'bg-[#cf222e]' },
|
{ id: 'qwen', label: '阿里 Qwen', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', keyUrl: 'https://bailian.console.aliyun.com/' },
|
||||||
{ name: '青色', value: '#0e7c7b', class: 'bg-[#0e7c7b]' },
|
{ id: 'kimi', label: 'Kimi', endpoint: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', keyUrl: 'https://platform.moonshot.cn/console/api-keys' },
|
||||||
|
{ id: 'ollama', label: 'Ollama 本地', endpoint: 'http://localhost:11434/v1', model: 'qwen2.5:7b', keyUrl: '' },
|
||||||
|
{ id: 'custom', label: '自定义…', endpoint: '', model: '', keyUrl: '' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 按 endpoint 反匹配预设(匹配不到返回 custom)
|
||||||
|
function matchProviderByEndpoint(endpoint?: string | null): string {
|
||||||
|
if (!endpoint) return 'custom';
|
||||||
|
const hit = LLM_PROVIDERS.find((p) => p.id !== 'custom' && p.endpoint === endpoint);
|
||||||
|
return hit ? hit.id : 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
export default function SystemSettings() {
|
export default function SystemSettings() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { change: themeChange } = useTheme();
|
||||||
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
|
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
|
||||||
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
|
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
|
||||||
const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' });
|
const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' });
|
||||||
|
const [llmProvider, setLlmProvider] = useState('custom');
|
||||||
|
// 先测试才能保存:null=未测 true=通过;任何表单修改都会重置
|
||||||
|
const [llmTestOk, setLlmTestOk] = useState<boolean | null>(null);
|
||||||
|
const updateLlm = (patch: Partial<typeof llm>) => {
|
||||||
|
setLlm((prev) => ({ ...prev, ...patch }));
|
||||||
|
setLlmTestOk(null);
|
||||||
|
};
|
||||||
|
const [lark, setLark] = useState({ enabled: false, webhook: '', secret: '' });
|
||||||
|
const [ldap, setLdap] = useState({
|
||||||
|
enabled: false,
|
||||||
|
host: '',
|
||||||
|
port: '389',
|
||||||
|
useSSL: false,
|
||||||
|
bindDN: '',
|
||||||
|
bindPassword: '',
|
||||||
|
baseDN: '',
|
||||||
|
userFilter: '(objectClass=inetOrgPerson)',
|
||||||
|
usernameAttr: 'uid',
|
||||||
|
nameAttr: 'displayName',
|
||||||
|
emailAttr: 'mail',
|
||||||
|
});
|
||||||
|
// 测试连接的可选「测试账号」展开态
|
||||||
|
const [ldapTestOpen, setLdapTestOpen] = useState(false);
|
||||||
|
const [ldapTest, setLdapTest] = useState({ username: '', password: '' });
|
||||||
|
|
||||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||||
|
|
||||||
// 用后端数据初始化表单(仅在数据变化时覆盖本地编辑态)
|
// 用后端数据初始化表单:仅在首次拿到数据时执行一次,
|
||||||
|
// 避免后续 settings 刷新(保存其它配置后 invalidate)把用户正在编辑的表单打回已保存值
|
||||||
|
const initedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings) return;
|
if (!settings || initedRef.current) return;
|
||||||
|
initedRef.current = true;
|
||||||
setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme));
|
setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme));
|
||||||
setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications));
|
setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications));
|
||||||
setLlm((prev) => ({
|
setLlm((prev) => ({
|
||||||
@@ -82,8 +111,100 @@ export default function SystemSettings() {
|
|||||||
model: settings.llmModel || '',
|
model: settings.llmModel || '',
|
||||||
apiKey: '', // 只写:回显永远留空
|
apiKey: '', // 只写:回显永远留空
|
||||||
}));
|
}));
|
||||||
|
setLlmProvider(matchProviderByEndpoint(settings.llmEndpoint));
|
||||||
|
setLark((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: !!settings.larkEnabled,
|
||||||
|
webhook: settings.larkWebhook || '',
|
||||||
|
secret: '',
|
||||||
|
}));
|
||||||
|
setLdap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: !!settings.ldapEnabled,
|
||||||
|
host: settings.ldapHost || '',
|
||||||
|
port: settings.ldapPort ? String(settings.ldapPort) : '389',
|
||||||
|
useSSL: !!settings.ldapUseSSL,
|
||||||
|
bindDN: settings.ldapBindDN || '',
|
||||||
|
bindPassword: '', // 只写:回显永远留空
|
||||||
|
baseDN: settings.ldapBaseDN || '',
|
||||||
|
userFilter: settings.ldapUserFilter || '(objectClass=inetOrgPerson)',
|
||||||
|
usernameAttr: settings.ldapUsernameAttr || 'uid',
|
||||||
|
nameAttr: settings.ldapNameAttr || 'displayName',
|
||||||
|
emailAttr: settings.ldapEmailAttr || 'mail',
|
||||||
|
}));
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
|
const saveLarkMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const body: any = { larkEnabled: lark.enabled, larkWebhook: lark.webhook };
|
||||||
|
if (lark.secret) body.larkSecret = lark.secret;
|
||||||
|
return apiClient.updateSettings(body);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('飞书通知配置已保存');
|
||||||
|
setLark((p) => ({ ...p, secret: '' }));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testLarkMutation = useMutation({
|
||||||
|
mutationFn: () => apiClient.testLark(),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
if (res.success) toast.success('测试卡片已发送', { description: '请到飞书群查看' });
|
||||||
|
else toast.error('发送失败', { description: res.message });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('测试失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveLdapMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const body: any = {
|
||||||
|
ldapEnabled: true,
|
||||||
|
ldapHost: ldap.host,
|
||||||
|
ldapPort: Number(ldap.port) || 389,
|
||||||
|
ldapUseSSL: ldap.useSSL,
|
||||||
|
ldapBindDN: ldap.bindDN,
|
||||||
|
ldapBaseDN: ldap.baseDN,
|
||||||
|
ldapUserFilter: ldap.userFilter,
|
||||||
|
ldapUsernameAttr: ldap.usernameAttr,
|
||||||
|
ldapNameAttr: ldap.nameAttr,
|
||||||
|
ldapEmailAttr: ldap.emailAttr,
|
||||||
|
};
|
||||||
|
if (ldap.bindPassword) body.ldapBindPassword = ldap.bindPassword;
|
||||||
|
return apiClient.updateSettings(body);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('LDAP 配置已保存', { description: '可在「用户管理」页从 LDAP 导入用户' });
|
||||||
|
setLdap((p) => ({ ...p, bindPassword: '' }));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testLdapMutation = useMutation({
|
||||||
|
// 草稿直测:用当前表单值(无需先保存),Bind 密码留空时后端回退用已存密码
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient.testLDAP({
|
||||||
|
enabled: true,
|
||||||
|
host: ldap.host,
|
||||||
|
port: Number(ldap.port) || 389,
|
||||||
|
useSSL: ldap.useSSL,
|
||||||
|
bindDN: ldap.bindDN,
|
||||||
|
bindPassword: ldap.bindPassword || undefined,
|
||||||
|
baseDN: ldap.baseDN,
|
||||||
|
userFilter: ldap.userFilter,
|
||||||
|
usernameAttr: ldap.usernameAttr,
|
||||||
|
testUsername: ldapTest.username || undefined,
|
||||||
|
testPassword: ldapTest.password || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
if (res.success) toast.success('LDAP 连接成功', { description: res.message });
|
||||||
|
else toast.error('LDAP 连接失败', { description: res.message });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('测试失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
const saveLlmMutation = useMutation({
|
const saveLlmMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
const body: any = { llmEnabled: llm.enabled, llmEndpoint: llm.endpoint, llmModel: llm.model };
|
const body: any = { llmEnabled: llm.enabled, llmEndpoint: llm.endpoint, llmModel: llm.model };
|
||||||
@@ -99,8 +220,15 @@ export default function SystemSettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const testLlmMutation = useMutation({
|
const testLlmMutation = useMutation({
|
||||||
mutationFn: apiClient.testLLM,
|
// 草稿直测:用当前表单值(无需先保存)
|
||||||
|
mutationFn: () => apiClient.testLLMDraft({
|
||||||
|
llmEnabled: true,
|
||||||
|
llmEndpoint: llm.endpoint,
|
||||||
|
llmModel: llm.model,
|
||||||
|
llmApiKey: llm.apiKey || undefined,
|
||||||
|
}),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
|
setLlmTestOk(!!res.success);
|
||||||
if (res.success) toast.success('AI 连接成功', { description: res.message });
|
if (res.success) toast.success('AI 连接成功', { description: res.message });
|
||||||
else toast.error('AI 连接失败', { description: res.message });
|
else toast.error('AI 连接失败', { description: res.message });
|
||||||
},
|
},
|
||||||
@@ -125,6 +253,21 @@ export default function SystemSettings() {
|
|||||||
toast.info('已恢复默认设置', { description: '仅本地重置,点击「保存设置」后生效' });
|
toast.info('已恢复默认设置', { description: '仅本地重置,点击「保存设置」后生效' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 顶部 Tab 分页:通用 / AI 助手 / 消息通知 / 用户目录
|
||||||
|
type SettingsTab = 'general' | 'ai' | 'notify' | 'directory';
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
|
||||||
|
|
||||||
|
const aiOn = !!settings?.llmEnabled && !!settings?.llmApiKeySet;
|
||||||
|
const larkOn = !!settings?.larkEnabled && !!settings?.larkWebhook;
|
||||||
|
const ldapOn = !!settings?.ldapEnabled && !!settings?.ldapHost && !!settings?.ldapBindDN && !!settings?.ldapBaseDN;
|
||||||
|
|
||||||
|
const tabs: { id: SettingsTab; label: string; dot?: boolean | null }[] = [
|
||||||
|
{ id: 'general', label: '通用' },
|
||||||
|
{ id: 'ai', label: 'AI 助手', dot: aiOn },
|
||||||
|
{ id: 'notify', label: '消息通知', dot: larkOn },
|
||||||
|
{ id: 'directory', label: '用户目录', dot: ldapOn },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -137,7 +280,35 @@ export default function SystemSettings() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 栏(与流水线编辑器 TopBar 同款下划线样式) */}
|
||||||
|
<div className="px-5 border-b flex items-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setActiveTab(t.id)}
|
||||||
|
className="relative px-3 py-2 text-[12px] font-medium flex items-center gap-1.5"
|
||||||
|
style={{ color: activeTab === t.id ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{t.dot != null && (
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full" style={{ background: t.dot ? '#1a7f37' : 'var(--text-tertiary)', opacity: t.dot ? 1 : 0.5 }} title={t.dot ? '已启用' : '未启用'} />
|
||||||
|
)}
|
||||||
|
{activeTab === t.id && (
|
||||||
|
<span className="absolute bottom-0 left-1 right-1 h-0.5 rounded-full" style={{ background: 'var(--accent-blue)' }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||||
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
|
{activeTab === 'general' && (
|
||||||
|
<>
|
||||||
|
{/* 外观主题保存/恢复操作 */}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button onClick={handleReset} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
<button onClick={handleReset} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
<RotateCcw className="w-3.5 h-3.5" />恢复默认
|
<RotateCcw className="w-3.5 h-3.5" />恢复默认
|
||||||
</button>
|
</button>
|
||||||
@@ -147,11 +318,6 @@ export default function SystemSettings() {
|
|||||||
{saveMutation.isPending ? '保存中...' : '保存设置'}
|
{saveMutation.isPending ? '保存中...' : '保存设置'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
|
||||||
<div className="max-w-3xl mx-auto space-y-4">
|
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div className="px-4 py-2.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
|
<div className="px-4 py-2.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
|
||||||
@@ -161,14 +327,14 @@ export default function SystemSettings() {
|
|||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
{/* Mode */}
|
{/* Mode */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题模式</label>
|
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题模式(选择即生效)</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{[
|
{[
|
||||||
{ id: 'light', label: '浅色', icon: Sun },
|
{ id: 'light', label: '浅色', icon: Sun },
|
||||||
{ id: 'dark', label: '深色', icon: Moon },
|
{ id: 'dark', label: '深色', icon: Moon },
|
||||||
{ id: 'system', label: '跟随系统', icon: Monitor },
|
{ id: 'system', label: '跟随系统', icon: Monitor },
|
||||||
].map(m => (
|
].map(m => (
|
||||||
<button key={m.id} onClick={() => setTheme(p => ({ ...p, mode: m.id as ThemeConfig['mode'] }))}
|
<button key={m.id} onClick={() => { setTheme(p => ({ ...p, mode: m.id as ThemeConfig['mode'] })); themeChange(m.id as ThemeMode); }}
|
||||||
className="flex items-center gap-2 px-4 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
|
className="flex items-center gap-2 px-4 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
|
||||||
style={{
|
style={{
|
||||||
borderColor: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
borderColor: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||||
@@ -180,88 +346,6 @@ export default function SystemSettings() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary color */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题色</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{colorOptions.map(c => (
|
|
||||||
<button key={c.value} onClick={() => setTheme(p => ({ ...p, primaryColor: c.value }))}
|
|
||||||
className={`w-8 h-8 rounded-full ${c.class} flex items-center justify-center transition-transform hover:scale-110`}
|
|
||||||
title={c.name}>
|
|
||||||
{theme.primaryColor === c.value && <CheckCircle2 className="w-4 h-4 text-white" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Font size */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>字体大小</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{[
|
|
||||||
{ id: 'small', label: '小', size: '12px' },
|
|
||||||
{ id: 'medium', label: '中', size: '14px' },
|
|
||||||
{ id: 'large', label: '大', size: '16px' },
|
|
||||||
].map(f => (
|
|
||||||
<button key={f.id} onClick={() => setTheme(p => ({ ...p, fontSize: f.id as ThemeConfig['fontSize'] }))}
|
|
||||||
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors text-center"
|
|
||||||
style={{
|
|
||||||
borderColor: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
|
||||||
background: theme.fontSize === f.id ? 'var(--accent-blue-light)' : 'transparent',
|
|
||||||
color: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
|
||||||
fontSize: f.size,
|
|
||||||
}}>
|
|
||||||
{f.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Density */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>界面密度</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{[
|
|
||||||
{ id: 'compact', label: '紧凑' },
|
|
||||||
{ id: 'comfortable', label: '舒适' },
|
|
||||||
{ id: 'spacious', label: '宽松' },
|
|
||||||
].map(d => (
|
|
||||||
<button key={d.id} onClick={() => setTheme(p => ({ ...p, density: d.id as ThemeConfig['density'] }))}
|
|
||||||
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
|
|
||||||
style={{
|
|
||||||
borderColor: theme.density === d.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
|
||||||
background: theme.density === d.id ? 'var(--accent-blue-light)' : 'transparent',
|
|
||||||
color: theme.density === d.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
|
||||||
}}>
|
|
||||||
{d.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Border radius */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>圆角风格</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{[
|
|
||||||
{ id: 'sharp', label: '直角', radius: '0px' },
|
|
||||||
{ id: 'soft', label: '圆角', radius: '6px' },
|
|
||||||
{ id: 'round', label: '大圆角', radius: '12px' },
|
|
||||||
].map(r => (
|
|
||||||
<button key={r.id} onClick={() => setTheme(p => ({ ...p, borderRadius: r.id as ThemeConfig['borderRadius'] }))}
|
|
||||||
className="flex-1 py-[7px] text-[12px] font-medium border transition-colors"
|
|
||||||
style={{
|
|
||||||
borderColor: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
|
||||||
background: theme.borderRadius === r.id ? 'var(--accent-blue-light)' : 'transparent',
|
|
||||||
color: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
|
||||||
borderRadius: r.radius,
|
|
||||||
}}>
|
|
||||||
{r.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -302,20 +386,53 @@ export default function SystemSettings() {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>界面语言</label>
|
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>界面语言</label>
|
||||||
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||||
<option>简体中文</option><option>English</option><option>日本語</option>
|
<option>简体中文</option><option>English</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>时区</label>
|
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>时区</label>
|
||||||
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||||
<option>Asia/Shanghai (UTC+8)</option><option>UTC</option><option>America/New_York</option>
|
<optgroup label="亚洲">
|
||||||
|
<option>Asia/Shanghai (UTC+8) 中国标准时间</option>
|
||||||
|
<option>Asia/Hong_Kong (UTC+8) 香港时间</option>
|
||||||
|
<option>Asia/Taipei (UTC+8) 台北时间</option>
|
||||||
|
<option>Asia/Singapore (UTC+8) 新加坡时间</option>
|
||||||
|
<option>Asia/Tokyo (UTC+9) 日本时间</option>
|
||||||
|
<option>Asia/Seoul (UTC+9) 韩国时间</option>
|
||||||
|
<option>Asia/Bangkok (UTC+7) 泰国时间</option>
|
||||||
|
<option>Asia/Dubai (UTC+4) 迪拜时间</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="欧洲">
|
||||||
|
<option>Europe/London (UTC+0/+1) 英国时间</option>
|
||||||
|
<option>Europe/Berlin (UTC+1/+2) 中欧时间</option>
|
||||||
|
<option>Europe/Paris (UTC+1/+2) 法国时间</option>
|
||||||
|
<option>Europe/Moscow (UTC+3) 莫斯科时间</option>
|
||||||
|
<option>Europe/Istanbul (UTC+3) 土耳其时间</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="美洲">
|
||||||
|
<option>America/New_York (UTC-5/-4) 美东时间</option>
|
||||||
|
<option>America/Chicago (UTC-6/-5) 美中时间</option>
|
||||||
|
<option>America/Denver (UTC-7/-6) 美山时间</option>
|
||||||
|
<option>America/Los_Angeles (UTC-8/-7) 美西时间</option>
|
||||||
|
<option>America/Sao_Paulo (UTC-3) 巴西时间</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="大洋洲 / 非洲 / 标准">
|
||||||
|
<option>Australia/Sydney (UTC+10/+11) 澳洲东部时间</option>
|
||||||
|
<option>Australia/Perth (UTC+8) 澳洲西部时间</option>
|
||||||
|
<option>Africa/Cairo (UTC+2) 埃及时间</option>
|
||||||
|
<option>Africa/Johannesburg (UTC+2) 南非时间</option>
|
||||||
|
<option>UTC 协调世界时</option>
|
||||||
|
</optgroup>
|
||||||
</select></div>
|
</select></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>语言与时区暂未生效,仅作展示</p>
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>语言与时区暂未生效,仅作展示</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'ai' && (
|
||||||
|
<>
|
||||||
{/* AI 助手配置 */}
|
{/* AI 助手配置 */}
|
||||||
<div className="rounded-lg border overflow-hidden mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div className="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
<div className="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>AI 助手(智能发布)</span>
|
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>AI 助手(智能发布)</span>
|
||||||
@@ -326,34 +443,237 @@ export default function SystemSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" checked={llm.enabled} onChange={(e) => setLlm({ ...llm, enabled: e.target.checked })} />
|
<input type="checkbox" checked={llm.enabled} onChange={(e) => updateLlm({ enabled: e.target.checked })} />
|
||||||
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用 AI(OpenAI 兼容接口:DeepSeek / GLM / Qwen 等)</span>
|
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用 AI(OpenAI 兼容接口:DeepSeek / GLM / Qwen 等)</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>服务商预设</label>
|
||||||
|
<select
|
||||||
|
value={llmProvider}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value;
|
||||||
|
setLlmProvider(id);
|
||||||
|
const preset = LLM_PROVIDERS.find((p) => p.id === id);
|
||||||
|
if (preset && id !== 'custom') {
|
||||||
|
// 非 custom:自动填入 endpoint/model(用户仍可修改)
|
||||||
|
updateLlm({ endpoint: preset.endpoint, model: preset.model });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{LLM_PROVIDERS.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>接口地址</label>
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>接口地址</label>
|
||||||
<input value={llm.endpoint} onChange={(e) => setLlm({ ...llm, endpoint: e.target.value })} placeholder="https://api.deepseek.com/v1" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input value={llm.endpoint} onChange={(e) => updateLlm({ endpoint: e.target.value })} placeholder="https://api.deepseek.com/v1" className="flex-1 min-w-0 px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
{!!LLM_PROVIDERS.find((p) => p.id === llmProvider)?.keyUrl && (
|
||||||
|
<a
|
||||||
|
href={LLM_PROVIDERS.find((p) => p.id === llmProvider)!.keyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="打开 API Key 申请页面"
|
||||||
|
className="flex-shrink-0 flex items-center gap-1 px-2 py-[6px] text-[11px] rounded-md border whitespace-nowrap"
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />获取 API Key
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>模型</label>
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>模型</label>
|
||||||
<input value={llm.model} onChange={(e) => setLlm({ ...llm, model: e.target.value })} placeholder="deepseek-chat" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
<input value={llm.model} onChange={(e) => updateLlm({ model: e.target.value })} placeholder="deepseek-chat" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 col-span-2">
|
<div className="space-y-1.5 col-span-2">
|
||||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Key</label>
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Key</label>
|
||||||
<input type="password" value={llm.apiKey} onChange={(e) => setLlm({ ...llm, apiKey: e.target.value })} placeholder={settings?.llmApiKeySet ? '已配置,留空不修改' : 'sk-...'} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
<input type="password" value={llm.apiKey} onChange={(e) => updateLlm({ apiKey: e.target.value })} placeholder={settings?.llmApiKeySet ? '已配置,留空不修改' : 'sk-...'} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>未启用 AI 时,智能助手自动降级为本地规则模式(按服务名匹配,仍可正常发布)</p>
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>未启用 AI 时,智能助手自动降级为本地规则模式(按服务名匹配,仍可正常发布)</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => saveLlmMutation.mutate()} disabled={saveLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
<button
|
||||||
|
onClick={() => saveLlmMutation.mutate()}
|
||||||
|
disabled={saveLlmMutation.isPending || (llm.enabled && llmTestOk !== true)}
|
||||||
|
title={llm.enabled && llmTestOk !== true ? '请先「测试连接」通过后再保存' : undefined}
|
||||||
|
className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
{saveLlmMutation.isPending ? '保存中…' : '保存 AI 配置'}
|
{saveLlmMutation.isPending ? '保存中…' : '保存 AI 配置'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => testLlmMutation.mutate()} disabled={testLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
<button onClick={() => testLlmMutation.mutate()} disabled={testLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
{testLlmMutation.isPending ? '测试中…' : '测试连接(需先保存)'}
|
{testLlmMutation.isPending ? '测试中…' : '测试连接'}
|
||||||
|
</button>
|
||||||
|
{llm.enabled && llmTestOk === true && (
|
||||||
|
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: '#dafbe1', color: '#1a7f37' }}>✓ 测试通过,可保存</span>
|
||||||
|
)}
|
||||||
|
{llm.enabled && llmTestOk === false && (
|
||||||
|
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: '#ffebe9', color: '#cf222e' }}>测试未通过,请检查配置</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{llm.enabled && llmTestOk !== true && (
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>启用 AI 后需先「测试连接」通过才能保存,避免保存不可用配置</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'notify' && (
|
||||||
|
<>
|
||||||
|
{/* 飞书 / Lark 通知 */}
|
||||||
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>飞书 / Lark 群通知</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: settings?.larkEnabled && settings?.larkWebhook ? '#dafbe1' : '#f6f8fa', color: settings?.larkEnabled && settings?.larkWebhook ? '#1a7f37' : '#6e7781' }}>
|
||||||
|
{settings?.larkEnabled && settings?.larkWebhook ? '已启用' : '未启用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
发布成功/失败后向群推送结果卡片(应用 / 分支 / 构建 / 状态 / 时间 + 「查看构建详情」按钮跳转 Jenkins 构建页)。
|
||||||
|
流水线单独配置的「通知 Webhook」优先生效。
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={lark.enabled} onChange={(e) => setLark({ ...lark, enabled: e.target.checked })} />
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用全局群通知</span>
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>群机器人 Webhook URL</label>
|
||||||
|
<input value={lark.webhook} onChange={(e) => setLark({ ...lark, webhook: e.target.value })} placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxxx" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>签名密钥(可选,机器人开启签名校验时填写)</label>
|
||||||
|
<input type="password" value={lark.secret} onChange={(e) => setLark({ ...lark, secret: e.target.value })} placeholder={settings?.larkSecretSet ? '已配置,留空不修改' : '无需签名可留空'} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => saveLarkMutation.mutate()} disabled={saveLarkMutation.isPending} className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||||
|
{saveLarkMutation.isPending ? '保存中…' : '保存通知配置'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => testLarkMutation.mutate()} disabled={testLarkMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
{testLarkMutation.isPending ? '发送中…' : '发送测试卡片(需先保存)'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<WebhookManager />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'directory' && (
|
||||||
|
<>
|
||||||
|
{/* LDAP 目录服务 */}
|
||||||
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>LDAP 目录服务</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: settings?.ldapEnabled ? '#dafbe1' : '#f6f8fa', color: settings?.ldapEnabled ? '#1a7f37' : '#6e7781' }}>
|
||||||
|
{settings?.ldapEnabled ? '已启用' : '未启用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!ldap.enabled ? (
|
||||||
|
<div className="p-5 space-y-3">
|
||||||
|
<p className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
LDAP 未启用——打开开关填写目录服务信息,启用后可在用户管理页导入用户
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={ldap.enabled} onChange={(e) => setLdap({ ...ldap, enabled: e.target.checked })} />
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用 LDAP 目录服务</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>服务器地址</label>
|
||||||
|
<input value={ldap.host} onChange={(e) => setLdap({ ...ldap, host: e.target.value })} placeholder="ldap.example.com" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>端口</label>
|
||||||
|
<input value={ldap.port} onChange={(e) => setLdap({ ...ldap, port: e.target.value })} placeholder="389" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={ldap.useSSL} onChange={(e) => setLdap({ ...ldap, useSSL: e.target.checked })} />
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>使用 LDAPS(SSL/TLS 加密连接,端口通常为 636)</span>
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Bind DN(绑定账号)</label>
|
||||||
|
<input value={ldap.bindDN} onChange={(e) => setLdap({ ...ldap, bindDN: e.target.value })} placeholder="cn=admin,dc=example,dc=com" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Bind 密码</label>
|
||||||
|
<input type="password" value={ldap.bindPassword} onChange={(e) => setLdap({ ...ldap, bindPassword: e.target.value })} placeholder={settings?.ldapBindPasswordSet ? '已保存,输入新密码以修改' : ''} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Base DN(用户搜索基准)</label>
|
||||||
|
<input value={ldap.baseDN} onChange={(e) => setLdap({ ...ldap, baseDN: e.target.value })} placeholder="ou=users,dc=example,dc=com" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户过滤器</label>
|
||||||
|
<input value={ldap.userFilter} onChange={(e) => setLdap({ ...ldap, userFilter: e.target.value })} placeholder="(objectClass=inetOrgPerson)" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名属性</label>
|
||||||
|
<input value={ldap.usernameAttr} onChange={(e) => setLdap({ ...ldap, usernameAttr: e.target.value })} placeholder="uid" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>姓名属性</label>
|
||||||
|
<input value={ldap.nameAttr} onChange={(e) => setLdap({ ...ldap, nameAttr: e.target.value })} placeholder="displayName" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>邮箱属性</label>
|
||||||
|
<input value={ldap.emailAttr} onChange={(e) => setLdap({ ...ldap, emailAttr: e.target.value })} placeholder="mail" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 可选:测试账号级验证 */}
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setLdapTestOpen(!ldapTestOpen)} className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--accent-blue)' }}>
|
||||||
|
{ldapTestOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||||
|
测试账号(可选,验证指定用户能否通过目录登录)
|
||||||
|
</button>
|
||||||
|
{ldapTestOpen && (
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-2.5">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>测试用户名</label>
|
||||||
|
<input value={ldapTest.username} onChange={(e) => setLdapTest({ ...ldapTest, username: e.target.value })} placeholder="zhangsan" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>测试密码</label>
|
||||||
|
<input type="password" value={ldapTest.password} onChange={(e) => setLdapTest({ ...ldapTest, password: e.target.value })} placeholder="目录服务中的密码" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
「测试连接」按当前表单草稿直测(Bind 密码留空时使用已保存密码),无需先保存。保存后可在「用户管理」页从 LDAP 批量导入用户。
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => saveLdapMutation.mutate()} disabled={saveLdapMutation.isPending} className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||||
|
{saveLdapMutation.isPending ? '保存中…' : '保存 LDAP 配置'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => testLdapMutation.mutate()} disabled={testLdapMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
{testLdapMutation.isPending ? '测试中…' : '测试连接'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Terminal,
|
Terminal,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -26,6 +27,7 @@ interface DeployTool {
|
|||||||
installedVersion: string;
|
installedVersion: string;
|
||||||
installMode: 'preinstalled' | 'lazy' | 'disabled';
|
installMode: 'preinstalled' | 'lazy' | 'disabled';
|
||||||
path: string;
|
path: string;
|
||||||
|
installHint: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
lastCheck: string | null;
|
lastCheck: string | null;
|
||||||
errorMsg: string;
|
errorMsg: string;
|
||||||
@@ -176,7 +178,7 @@ export default function ToolConfig() {
|
|||||||
{/* Description bar */}
|
{/* Description bar */}
|
||||||
<div className="px-5 py-2 border-b text-[12px] flex items-center gap-2" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>
|
<div className="px-5 py-2 border-b text-[12px] flex items-center gap-2" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>
|
||||||
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
管理 DevOps 工具的安装策略:预装在镜像中、首次使用自动下载(懒加载)、或禁用不安装
|
构建环境建议 Ubuntu 22.04(apt 安装下方工具);Node 由 nvm 管理——默认版本可在此调整,流水线构建时用 <code className="px-1 rounded font-mono text-[11px]" style={{ background: 'var(--panel-bg)', color: 'var(--accent-blue)' }}>nvm install $NODE_VERSION</code> 灵活指定版本。可用 deploy/Dockerfile.buildnode 一键构建节点镜像
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -236,6 +238,17 @@ export default function ToolConfig() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-[14px] font-semibold mb-0.5" style={{ color: 'var(--text-primary)' }}>{tool.name}</div>
|
<div className="text-[14px] font-semibold mb-0.5" style={{ color: 'var(--text-primary)' }}>{tool.name}</div>
|
||||||
<div className="text-[12px] font-mono mb-1" style={{ color: 'var(--text-tertiary)' }}>{tool.key}</div>
|
<div className="text-[12px] font-mono mb-1" style={{ color: 'var(--text-tertiary)' }}>{tool.key}</div>
|
||||||
|
{tool.installHint && tool.status !== 'installed' && (
|
||||||
|
<button
|
||||||
|
onClick={() => { navigator.clipboard.writeText(tool.installHint); toast.success('安装命令已复制'); }}
|
||||||
|
className="mt-1 flex items-center gap-1.5 px-2 py-[3px] rounded font-mono text-[10px] max-w-full hover:opacity-80"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}
|
||||||
|
title={tool.installHint}
|
||||||
|
>
|
||||||
|
<Copy className="w-2.5 h-2.5 flex-shrink-0" />
|
||||||
|
<span className="truncate">{tool.installHint}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{tool.status === 'installed' && tool.path && (
|
{tool.status === 'installed' && tool.path && (
|
||||||
<div className="text-[10px] mt-1 font-mono" style={{ color: 'var(--text-tertiary)' }}>路径: {tool.path}</div>
|
<div className="text-[10px] mt-1 font-mono" style={{ color: 'var(--text-tertiary)' }}>路径: {tool.path}</div>
|
||||||
)}
|
)}
|
||||||
@@ -262,7 +275,7 @@ export default function ToolConfig() {
|
|||||||
) : (
|
) : (
|
||||||
tool.version && (
|
tool.version && (
|
||||||
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={tool.errorMsg || undefined}>
|
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={tool.errorMsg || undefined}>
|
||||||
期望 v{tool.version}
|
{tool.key === 'nodejs' ? `默认 v${tool.version}(构建时可覆盖)` : `期望 v${tool.version}`}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,696 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
KeyRound,
|
||||||
|
Trash2,
|
||||||
|
ShieldCheck,
|
||||||
|
Copy,
|
||||||
|
CloudDownload,
|
||||||
|
AlertCircle,
|
||||||
|
UserCog,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient, auth } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
function fmtTime(v?: string): string {
|
||||||
|
if (!v || v.startsWith('0001-')) return '—';
|
||||||
|
return v.replace('T', ' ').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleBadge = {
|
||||||
|
admin: { label: '管理员', bg: '#dafbe1', color: '#1a7f37' },
|
||||||
|
user: { label: '普通用户', bg: '#f6f8fa', color: '#6e7781' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const sourceBadge = {
|
||||||
|
ldap: { label: 'LDAP 导入', bg: '#fbefff', color: '#8250df' },
|
||||||
|
local: { label: '本地账号', bg: '#ddf4ff', color: '#0969da' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface UserForm {
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: UserForm = { username: '', nickname: '', email: '', role: 'user', password: '' };
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
background: 'var(--panel-bg-secondary)',
|
||||||
|
borderColor: 'var(--border-color-light)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LdapEntry {
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
created: number;
|
||||||
|
converted: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
failedUsers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||||
|
const [createdResult, setCreatedResult] = useState<{ username: string; password: string } | null>(null);
|
||||||
|
const [ldapOpen, setLdapOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
|
// 批量删除
|
||||||
|
const [batchIds, setBatchIds] = useState<Set<number>>(new Set());
|
||||||
|
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||||
|
const currentUsername = (() => {
|
||||||
|
try {
|
||||||
|
const token = auth.getToken() || '';
|
||||||
|
const raw = atob(token.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
return raw.split('|')[0];
|
||||||
|
} catch { return ''; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const toggleBatch = (id: number) => {
|
||||||
|
setBatchIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchDelete = async () => {
|
||||||
|
const targets = users.filter((u: any) => batchIds.has(u.id));
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
const preview = targets.slice(0, 5).map((u: any) => u.username).join('、') + (targets.length > 5 ? ` 等 ${targets.length} 个` : '');
|
||||||
|
if (!window.confirm(`确定删除 ${preview} 吗?`)) return;
|
||||||
|
setBatchDeleting(true);
|
||||||
|
let ok = 0;
|
||||||
|
const failed: string[] = [];
|
||||||
|
for (const u of targets) {
|
||||||
|
try {
|
||||||
|
await apiClient.deleteUser(u.id);
|
||||||
|
ok++;
|
||||||
|
} catch (err) {
|
||||||
|
failed.push(`${u.username}(${(err as Error).message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBatchDeleting(false);
|
||||||
|
setBatchIds(new Set());
|
||||||
|
invalidateUsers();
|
||||||
|
if (failed.length === 0) toast.success(`已删除 ${ok} 个用户`);
|
||||||
|
else toast.warning(`删除完成:成功 ${ok},失败 ${failed.length}`, { description: failed.join(';') });
|
||||||
|
};
|
||||||
|
|
||||||
|
const usersQuery = useQuery({ queryKey: ['users'], queryFn: apiClient.listUsers });
|
||||||
|
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||||
|
|
||||||
|
// LDAP 就绪(已启用且关键配置齐全)时才显示「从 LDAP 导入」入口
|
||||||
|
const ldapReady = !!(settings?.ldapEnabled && settings?.ldapHost && settings?.ldapBindDN && settings?.ldapBaseDN);
|
||||||
|
|
||||||
|
const users: any[] = usersQuery.data ?? [];
|
||||||
|
|
||||||
|
const selectableUsers = users.filter((u: any) => u.username !== currentUsername);
|
||||||
|
const allChecked = selectableUsers.length > 0 && selectableUsers.every((u: any) => batchIds.has(u.id));
|
||||||
|
|
||||||
|
const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
|
||||||
|
// LDAP 预览列表(仅弹窗打开且未进入结果态时请求)
|
||||||
|
const ldapQuery = useQuery({
|
||||||
|
queryKey: ['ldap-users'],
|
||||||
|
queryFn: apiClient.ldapListUsers,
|
||||||
|
enabled: ldapOpen && !importResult,
|
||||||
|
});
|
||||||
|
const ldapEntries: LdapEntry[] = (ldapQuery.data as LdapEntry[]) ?? [];
|
||||||
|
|
||||||
|
// 本地用户索引(按用户名小写匹配,目录服务通常大小写不敏感)
|
||||||
|
const localMap = useMemo(() => {
|
||||||
|
const m = new Map<string, any>();
|
||||||
|
users.forEach((u) => m.set(String(u.username || '').toLowerCase(), u));
|
||||||
|
return m;
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
|
// ---- 新建用户 ----
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: any) => apiClient.createUser(body),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
invalidateUsers();
|
||||||
|
setDialogOpen(false);
|
||||||
|
if (res?.initialPassword) {
|
||||||
|
setCreatedResult({ username: form.username.trim(), password: String(res.initialPassword) });
|
||||||
|
} else {
|
||||||
|
toast.success('用户已创建');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitCreate = () => {
|
||||||
|
if (!form.username.trim()) {
|
||||||
|
toast.error('请填写用户名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body: any = {
|
||||||
|
username: form.username.trim(),
|
||||||
|
nickname: form.nickname.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
role: form.role,
|
||||||
|
};
|
||||||
|
if (form.password) body.password = form.password;
|
||||||
|
createMutation.mutate(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 重置密码 ----
|
||||||
|
const resetMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.resetUserPassword(id),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
if (res?.initialPassword) {
|
||||||
|
toast.success('密码已重置', { description: `一次性密码:${res.initialPassword}(仅显示一次,请立即保存)` });
|
||||||
|
} else {
|
||||||
|
toast.success('密码已重置');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 角色切换 ----
|
||||||
|
const roleMutation = useMutation({
|
||||||
|
mutationFn: (vars: { id: any; role: string }) => apiClient.updateUser(vars.id, { role: vars.role }),
|
||||||
|
onSuccess: (_d, vars) => {
|
||||||
|
toast.success(vars.role === 'admin' ? '已设为管理员' : '已降为普通用户');
|
||||||
|
invalidateUsers();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 删除 ----
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.deleteUser(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('用户已删除');
|
||||||
|
invalidateUsers();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- LDAP 导入 ----
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: (list: LdapEntry[]) => apiClient.ldapImport(list),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
setImportResult({
|
||||||
|
created: res?.created ?? 0,
|
||||||
|
converted: res?.converted ?? 0,
|
||||||
|
skipped: res?.skipped ?? 0,
|
||||||
|
failed: res?.failed ?? 0,
|
||||||
|
failedUsers: res?.failedUsers ?? [],
|
||||||
|
});
|
||||||
|
invalidateUsers();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('导入失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openLdapDialog = () => {
|
||||||
|
setSelected(new Set());
|
||||||
|
setImportResult(null);
|
||||||
|
setLdapOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeLdapDialog = (open: boolean) => {
|
||||||
|
setLdapOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setSelected(new Set());
|
||||||
|
setImportResult(null);
|
||||||
|
invalidateUsers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelected = (username: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(username)) next.delete(username);
|
||||||
|
else next.add(username);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedEntries = ldapEntries.filter((e) => selected.has(e.username));
|
||||||
|
|
||||||
|
const copyText = (text: string) => {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(text)
|
||||||
|
.then(() => toast.success('已复制', { description: text }))
|
||||||
|
.catch(() => toast.error('复制失败'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const importStats = importResult
|
||||||
|
? [
|
||||||
|
{ key: 'created', label: '新增', value: importResult.created, bg: '#dafbe1', color: '#1a7f37' },
|
||||||
|
{ key: 'converted', label: '转换', value: importResult.converted, bg: '#ddf4ff', color: '#0969da' },
|
||||||
|
{ key: 'skipped', label: '跳过', value: importResult.skipped, bg: '#f6f8fa', color: '#6e7781' },
|
||||||
|
{ key: 'failed', label: '失败', value: importResult.failed, bg: '#ffebe9', color: '#cf222e' },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
|
<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)' }}
|
||||||
|
>
|
||||||
|
{users.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{ldapReady && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
|
onClick={openLdapDialog}
|
||||||
|
>
|
||||||
|
<CloudDownload className="w-3.5 h-3.5" />
|
||||||
|
从 LDAP 导入
|
||||||
|
</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={() => {
|
||||||
|
setForm(emptyForm);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
新建用户
|
||||||
|
</button>
|
||||||
|
{batchIds.size > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={batchDelete}
|
||||||
|
disabled={batchDeleting}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-[6px] text-[12px] font-medium rounded-md border disabled:opacity-60"
|
||||||
|
style={{ background: '#ffebe9', borderColor: '#cf222e', color: '#cf222e' }}
|
||||||
|
>
|
||||||
|
{batchDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
批量删除({batchIds.size})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||||
|
{usersQuery.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>
|
||||||
|
) : usersQuery.isError ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-16">
|
||||||
|
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(usersQuery.error as Error).message}</div>
|
||||||
|
<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-primary)' }}
|
||||||
|
onClick={() => usersQuery.refetch()}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</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)' }}>
|
||||||
|
<th className="w-9 px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allChecked}
|
||||||
|
onChange={(e) => setBatchIds(e.target.checked ? new Set(selectableUsers.map((u: any) => u.id)) : new Set())}
|
||||||
|
title="全选(不含当前登录用户)"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
{['用户名', '昵称', '邮箱', '角色', '来源', '创建时间', '操作'].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>
|
||||||
|
{users.map((u) => {
|
||||||
|
const role = (roleBadge as any)[u.role] ?? roleBadge.user;
|
||||||
|
const source = (sourceBadge as any)[u.authSource] ?? sourceBadge.local;
|
||||||
|
const isAdmin = u.role === 'admin';
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
{u.username === currentUsername ? (
|
||||||
|
<input type="checkbox" disabled title="当前登录用户不可删除" />
|
||||||
|
) : (
|
||||||
|
<input type="checkbox" checked={batchIds.has(u.id)} onChange={() => toggleBatch(u.id)} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-[13px] font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{u.username}</span>
|
||||||
|
{u.username === currentUsername && (
|
||||||
|
<span className="ml-1.5 text-[10px]" style={{ color: 'var(--text-tertiary)' }}>(当前登录)</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{u.nickname || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{u.email || '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: role.bg, color: role.color }}>
|
||||||
|
{role.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: source.bg, color: source.color }}>
|
||||||
|
{source.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{fmtTime(u.createdAt)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
{u.authSource !== 'ldap' && (
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="重置密码"
|
||||||
|
disabled={resetMutation.isPending}
|
||||||
|
onClick={() => resetMutation.mutate(u.id)}
|
||||||
|
>
|
||||||
|
<KeyRound className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title={isAdmin ? '降为普通用户' : '设为管理员'}
|
||||||
|
disabled={roleMutation.isPending}
|
||||||
|
onClick={() => roleMutation.mutate({ id: u.id, role: isAdmin ? 'user' : 'admin' })}
|
||||||
|
>
|
||||||
|
{isAdmin ? (
|
||||||
|
<UserCog className="w-3.5 h-3.5" style={{ color: '#1a7f37' }} />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck 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(`确定删除用户 ${u.username} 吗?`)) deleteMutation.mutate(u.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{users.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-12 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
暂无用户,点击右上角「新建用户」创建
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新建用户弹窗 */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>新建用户</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名 *</Label>
|
||||||
|
<Input
|
||||||
|
className="h-8 text-[12px] font-mono"
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||||
|
placeholder="zhangsan"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<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.nickname}
|
||||||
|
onChange={(e) => setForm({ ...form, nickname: e.target.value })}
|
||||||
|
placeholder="张三"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>角色</Label>
|
||||||
|
<Select value={form.role} onValueChange={(v) => setForm({ ...form, role: v })}>
|
||||||
|
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
|
||||||
|
<SelectValue placeholder="选择角色" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user" className="text-[12px]">普通用户</SelectItem>
|
||||||
|
<SelectItem value="admin" className="text-[12px]">管理员</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
placeholder="zhangsan@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
className="h-8 text-[12px] font-mono"
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
placeholder="留空由系统随机生成"
|
||||||
|
/>
|
||||||
|
</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={createMutation.isPending}
|
||||||
|
onClick={submitCreate}
|
||||||
|
>
|
||||||
|
{createMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 创建成功:一次性密码 */}
|
||||||
|
<Dialog open={!!createdResult} onOpenChange={(open) => !open && setCreatedResult(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>用户已创建</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
用户 <span className="font-mono font-medium" style={{ color: 'var(--text-primary)' }}>{createdResult?.username}</span> 创建成功。
|
||||||
|
系统已生成一次性初始密码,仅显示此次,请立即复制并妥善交付给用户:
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2.5 rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
|
||||||
|
>
|
||||||
|
<code className="text-[14px] font-mono font-semibold tracking-wide break-all" style={{ color: 'var(--accent-blue)' }}>
|
||||||
|
{createdResult?.password}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-[11px] rounded border flex-shrink-0"
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
|
onClick={() => createdResult && copyText(createdResult.password)}
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
onClick={() => setCreatedResult(null)}
|
||||||
|
>
|
||||||
|
我已保存
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* LDAP 导入弹窗 */}
|
||||||
|
<Dialog open={ldapOpen} onOpenChange={closeLdapDialog}>
|
||||||
|
<DialogContent className="sm:max-w-lg" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>从 LDAP 导入用户</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{importResult ? (
|
||||||
|
/* 结果态:四色统计 + 失败名单 */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{importStats.map((s) => (
|
||||||
|
<div key={s.key} className="rounded-md px-3 py-2.5 text-center" style={{ background: s.bg }}>
|
||||||
|
<div className="text-[18px] font-bold" style={{ color: s.color }}>{s.value}</div>
|
||||||
|
<div className="text-[11px] font-medium" style={{ color: s.color }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{importResult.failedUsers.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-md border px-3 py-2.5 space-y-1.5"
|
||||||
|
style={{ background: '#ffebe9', borderColor: '#ff818266' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 text-[12px] font-medium" style={{ color: '#cf222e' }}>
|
||||||
|
<AlertCircle className="w-3.5 h-3.5" />
|
||||||
|
以下用户导入失败:
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{importResult.failedUsers.map((name) => (
|
||||||
|
<code key={name} className="text-[11px] font-mono px-1.5 py-[2px] rounded" style={{ background: '#ffffff99', color: '#cf222e' }}>
|
||||||
|
{name}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
「转换」表示本地同名账号已切换为 LDAP 来源;「跳过」表示该用户已是 LDAP 账号。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : ldapQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-12 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
正在从 LDAP 目录读取用户...
|
||||||
|
</div>
|
||||||
|
) : ldapQuery.isError ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-10">
|
||||||
|
<div className="text-[13px] text-center" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{(ldapQuery.error as Error).message}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>请到系统设置检查 LDAP 配置</div>
|
||||||
|
<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-primary)' }}
|
||||||
|
onClick={() => ldapQuery.refetch()}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 选择态 */
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="max-h-[340px] overflow-y-auto custom-scrollbar rounded-md border" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
{ldapEntries.map((entry) => {
|
||||||
|
const local = localMap.get(String(entry.username || '').toLowerCase());
|
||||||
|
const isImported = local?.authSource === 'ldap';
|
||||||
|
const willConvert = !!local && local.authSource !== 'ldap';
|
||||||
|
const checked = selected.has(entry.username);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={entry.username}
|
||||||
|
className={`flex items-center gap-2.5 px-3 py-2 border-b last:border-b-0 ${isImported ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', background: checked ? 'var(--accent-blue-light)' : 'transparent' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-3.5 h-3.5 flex-shrink-0"
|
||||||
|
style={{ accentColor: 'var(--accent-blue)' }}
|
||||||
|
checked={checked}
|
||||||
|
disabled={isImported}
|
||||||
|
onChange={() => toggleSelected(entry.username)}
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] font-mono font-medium flex-shrink-0" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{entry.username}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] truncate flex-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{[entry.displayName, entry.email].filter(Boolean).join(' · ') || '—'}
|
||||||
|
</span>
|
||||||
|
{isImported && (
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded flex-shrink-0" style={{ background: '#f6f8fa', color: '#6e7781' }}>已导入</span>
|
||||||
|
)}
|
||||||
|
{willConvert && (
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded flex-shrink-0" style={{ background: '#ddf4ff', color: '#0969da' }}>将转为 LDAP</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{ldapEntries.length === 0 && (
|
||||||
|
<div className="px-3 py-10 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
目录中未读取到用户
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
共 {ldapEntries.length} 个用户,已选 {selected.size} 个
|
||||||
|
</span>
|
||||||
|
<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={selected.size === 0 || importMutation.isPending}
|
||||||
|
onClick={() => importMutation.mutate(selectedEntries)}
|
||||||
|
>
|
||||||
|
{importMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
导入 {selected.size} 个用户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importResult && (
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
onClick={() => closeLdapDialog(false)}
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'flowstack-theme';
|
||||||
|
|
||||||
|
function isThemeMode(v: string | null): v is ThemeMode {
|
||||||
|
return v === 'light' || v === 'dark' || v === 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMode(mode: ThemeMode) {
|
||||||
|
const dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深浅主题:本地缓存先行应用避免闪白,远端设置为最终来源 */
|
||||||
|
export function useTheme() {
|
||||||
|
const [mode, setMode] = useState<ThemeMode>(() => {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return isThemeMode(saved) ? saved : 'light';
|
||||||
|
});
|
||||||
|
const [synced, setSynced] = useState(false);
|
||||||
|
const [isDark, setIsDark] = useState(() =>
|
||||||
|
typeof document !== 'undefined' && document.documentElement.classList.contains('dark'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 立即应用 + system 模式跟随系统变化
|
||||||
|
useEffect(() => {
|
||||||
|
applyMode(mode);
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode);
|
||||||
|
if (mode !== 'system') return;
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const onChange = () => {
|
||||||
|
applyMode('system');
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', onChange);
|
||||||
|
return () => mq.removeEventListener('change', onChange);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
// 启动后用远端设置校准一次(多端一致)
|
||||||
|
useEffect(() => {
|
||||||
|
if (synced) return;
|
||||||
|
apiClient.getSettings().then((s: any) => {
|
||||||
|
try {
|
||||||
|
const theme = JSON.parse(s?.theme || '{}');
|
||||||
|
if (isThemeMode(theme.mode)) setMode(theme.mode);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setSynced(true);
|
||||||
|
}).catch(() => setSynced(true));
|
||||||
|
}, [synced]);
|
||||||
|
|
||||||
|
const change = useCallback(async (next: ThemeMode) => {
|
||||||
|
setMode(next);
|
||||||
|
// 持久化到后端(失败静默,本地已生效)
|
||||||
|
try {
|
||||||
|
const s = await apiClient.getSettings();
|
||||||
|
let theme: any = {};
|
||||||
|
try { theme = JSON.parse(s?.theme || '{}'); } catch { /* ignore */ }
|
||||||
|
await apiClient.updateSettings({ theme: JSON.stringify({ ...theme, mode: next }) });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
change(isDark ? 'light' : 'dark');
|
||||||
|
}, [change]);
|
||||||
|
|
||||||
|
return { mode, isDark, change, toggle };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user