From 97193eb090745328376bcb998e24c6f8df3d4dc7 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Tue, 27 Oct 2026 12:58:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.4.0=20=E4=B8=BB=E9=A2=98=E5=88=87?= =?UTF-8?q?=E6=8D=A2=20+=20LLM=20=E5=85=A8=E9=9D=A2=E4=BC=98=E5=8C=96=20+?= =?UTF-8?q?=20=E7=99=BB=E5=BD=95=E4=BC=9A=E8=AF=9D=20+=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86/LDAP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主题: - 深色/浅色/跟随系统(useTheme:本地缓存先行+远端同步+matchMedia), 侧边栏快捷切换,设置页三档即选即生效 LLM(参考 opsxdb/aiops): - llm.Client.ChatStream 流式(SSE 增量解析,透传 reasoning_content, ctx 控制超时);POST /api/ai/chat/stream(平台实时数据注入降幻觉, 未配 LLM 降级规则 JSON,指令解析 action 不变) - test-llm 支持草稿直测(保存前验证,apiKey 空/掩码回退已存) - 发布失败自动 AI 分析日志尾部(结论/根因/建议),发布记录与发布 Tab 展示分析卡片 - 设置页 Provider 预设(DeepSeek/GLM/GLM编码/Qwen/Kimi/Ollama/自定义) 自动填 endpoint+模型+获取 Key 直达链接 登录与会话: - HMAC 签名 token(7 天,密钥派生自 encryption.key); authMiddleware 保护 /api/*(豁免登录/健康/SSE日志流/入站webhook) - 登录页 /login + 未登录跳转 + 登出;401 自动清理跳登录 用户管理与 LDAP(借鉴 LokiStack): - /users 页面:本地用户 CRUD、一次性密码、角色切换、来源徽章 (本地·蓝 | LDAP导入·紫);LDAP 导入按钮仅在启用且配置齐备时显示 - LDAP 配置卡(未启用仅提示语)、测试连接草稿直测+可选测试账号 - go-ldap:管理 Bind/用户搜索(防注入)/用户 DN 直连验证; 批量导入冲突策略(已LDAP跳过/本地同名转LDAP/新建); 登录分流本地 argon2id 或 LDAP 目录验证 + 首登 JIT 建号 --- README.md | 20 +- backend/go.mod | 21 +- backend/go.sum | 58 +- backend/internal/handler/aistream.go | 187 +++++++ backend/internal/handler/auth.go | 134 ++++- backend/internal/handler/jenkins.go | 3 + backend/internal/handler/pipelines.go | 47 +- backend/internal/handler/runs.go | 3 + backend/internal/handler/users.go | 380 +++++++++++++ backend/internal/ldap/ldap.go | 148 +++++ backend/internal/llm/client.go | 107 ++++ backend/internal/model/models.go | 15 + backend/main.go | 18 + backend/static/index.html | 2 +- frontend/src/App.tsx | 29 +- frontend/src/components/ReleasePanel.tsx | 22 +- frontend/src/components/Sidebar.tsx | 36 +- frontend/src/components/ai/AiAssistant.tsx | 203 ++++++- frontend/src/lib/api.ts | 33 +- frontend/src/pages/Login.tsx | 88 +++ frontend/src/pages/ReleaseRecords.tsx | 18 +- frontend/src/pages/SystemSettings.tsx | 253 ++++++++- frontend/src/pages/UserManage.tsx | 623 +++++++++++++++++++++ frontend/src/providers/theme.tsx | 72 +++ 24 files changed, 2422 insertions(+), 98 deletions(-) create mode 100644 backend/internal/handler/aistream.go create mode 100644 backend/internal/handler/users.go create mode 100644 backend/internal/ldap/ldap.go create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/UserManage.tsx create mode 100644 frontend/src/providers/theme.tsx diff --git a/README.md b/README.md index 1880546..2d73730 100644 --- a/README.md +++ b/README.md @@ -36,19 +36,27 @@ docker logs <容器名> | grep -A6 "已生成管理员账号" docker exec <容器名> cat /app/data/initial-admin-password.txt ``` -登录校验接口:`POST /api/auth/login`(body: `{"username": "admin", "password": "..."}`)。 -密码使用 argon2id 哈希存储,请登录后立即修改。 +登录校验接口:`POST /api/auth/login`(body: `{"username": "admin", "password": "..."}`,返回签名 token,前端自动携带)。 +密码使用 argon2id 哈希存储。启用 LDAP 后同名目录账号自动走 LDAP 验证(密码不落库)。 ## 功能模块 | 模块 | 说明 | 数据来源 | |------|------|----------| +| 登录/会话 | 本地账号 + LDAP 账号统一登录(JWT 式签名 token,7 天) | gin API | +| 主题 | 深色/浅色/跟随系统,侧边栏快捷切换,多端同步 | gin API(持久化) | | 流水线 | 列表 + 画布编辑器(Tekton 任务库、拖拽编排、连线、节点属性配置) | gin API(SQLite 持久化) | | 模拟执行 | 触发运行后节点状态实时推进(pending→running→success)+ SSE 日志流 | gin API + 模拟执行引擎 | -| GitOps 部署 | ArgoCD 风格应用列表 / 资源树 / Diff | gin API(部分模拟) | -| 插件市场 | 已安装 / 可安装 / 启停 / 卸载 | gin API(SQLite 持久化) | -| 发布记录 | 运行历史、阶段、日志、Pod 详情 | 前端演示数据(待接入) | -| 批量发布 / 环境 / 凭证 / 构建节点 / Jenkins 导入 / Webhook / 系统设置 | UI 演示 | 前端演示数据(待接入) | +| AI 智能助手 | 全局悬浮对话:自然语言发布指令解析(流式输出、平台数据注入、规则兜底)、发布失败自动 AI 分析日志 | OpenAI 兼容 LLM(DeepSeek/GLM/Qwen/Kimi/Ollama 预设) | +| 飞书/Lark 通知 | 发布结果交互卡片(应用/分支/构建/状态/时间 + 构建详情按钮),支持加签 | gin API | +| 用户管理 | 本地用户 CRUD + LDAP 用户导入(未启用 LDAP 时导入按钮隐藏),本地/LDAP 来源徽章 | gin API | +| 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 | ## 架构 diff --git a/backend/go.mod b/backend/go.mod index 100fc2f..4aae59b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,16 +1,18 @@ module flowstack -go 1.24 +go 1.26.0 require ( github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin 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 ) require ( + github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // 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/gin-contrib/sse v1.1.0 // 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/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // 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/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -40,12 +43,12 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.36.9 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 04651fc..d7d87fd 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= 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/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= 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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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/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/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +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/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -85,23 +107,23 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= 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/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +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/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/internal/handler/aistream.go b/backend/internal/handler/aistream.go new file mode 100644 index 0000000..b3d16c3 --- /dev/null +++ b/backend/internal/handler/aistream.go @@ -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) +} + diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index dafe5f1..7679f80 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -1,7 +1,17 @@ package handler import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" "github.com/gin-gonic/gin" @@ -10,7 +20,98 @@ import ( "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/SSE 日志流);SPA 与静态资源放行 +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + p := c.Request.URL.Path + if p == "/health" || !strings.HasPrefix(p, "/api/") || + p == "/api/auth/login" || + strings.HasPrefix(p, "/api/hooks/") || + strings.HasSuffix(p, "/logs/stream") { + c.Next() + return + } + auth := c.GetHeader("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + return + } + username, err := VerifyToken(strings.TrimPrefix(auth, "Bearer ")) + 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() + } +} + +// 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) { var body struct { Username string `json:"username" binding:"required"` @@ -21,16 +122,33 @@ func Login(c *gin.Context) { return } + // 1) 本地用户:AuthSource != ldap 才走本地口令 var user model.User - if err := database.DB.Where("username = ?", body.Username).First(&user).Error; err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + hasLocal := database.DB.Where("username = ?", body.Username).First(&user).Error == nil + if hasLocal && user.AuthSource != "ldap" { + if !crypto.VerifyPassword(body.Password, user.Password) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + 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 } - if !crypto.VerifyPassword(body.Password, user.Password) { - c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + + // 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.StatusOK, gin.H{ - "success": true, "username": user.Username, "nickname": user.Nickname, "role": user.Role, - }) + + // 统一错误,不泄露用户是否存在 + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) } diff --git a/backend/internal/handler/jenkins.go b/backend/internal/handler/jenkins.go index 35acb50..693a561 100644 --- a/backend/internal/handler/jenkins.go +++ b/backend/internal/handler/jenkins.go @@ -212,6 +212,9 @@ func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, j // 发布结束后回查最新构建信息再推飞书卡片(含 buildNumber/externalURL) var fresh model.PipelineRun database.DB.First(&fresh, run.ID) + if status == "failed" { + go analyzeFailure(fresh) + } go notifyLarkRelease(fresh, status) } diff --git a/backend/internal/handler/pipelines.go b/backend/internal/handler/pipelines.go index ca36d33..2be9301 100644 --- a/backend/internal/handler/pipelines.go +++ b/backend/internal/handler/pipelines.go @@ -2,6 +2,7 @@ package handler import ( "context" + "strings" "net/http" "time" @@ -91,6 +92,7 @@ func GetSettings(c *gin.Context) { } s.LlmApiKeySet = s.LlmApiKey != "" s.LarkSecretSet = s.LarkSecret != "" + s.LdapBindPasswordSet = s.LdapBindPassword != "" c.JSON(http.StatusOK, s) } @@ -158,27 +160,50 @@ func UpdateSettings(c *gin.Context) { database.DB.First(&s, 1) s.LlmApiKeySet = s.LlmApiKey != "" s.LarkSecretSet = s.LarkSecret != "" + s.LdapBindPasswordSet = s.LdapBindPassword != "" 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) { var s model.SystemSetting if err := database.DB.First(&s, 1).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请先保存设置"}) - return + s = model.SystemSetting{} } - if !s.LlmEnabled || s.LlmApiKey == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "AI 未启用或未配置 API Key"}) - return + var body struct { + LlmEnabled *bool `json:"llmEnabled"` + LlmEndpoint string `json:"llmEndpoint"` + LlmModel string `json:"llmModel"` + LlmApiKey string `json:"llmApiKey"` } - apiKey := s.LlmApiKey - if crypto.IsEncrypted(apiKey) { - if plain, err := crypto.DecryptField(apiKey); err == nil { - apiKey = plain + _ = 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 plain, err := crypto.DecryptField(apiKey); err == nil { + 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) defer cancel() reply, err := client.Chat(ctx, "你是连通性检查助手,只回复:连接成功。", "ping") diff --git a/backend/internal/handler/runs.go b/backend/internal/handler/runs.go index 6db71c4..dce8f2a 100644 --- a/backend/internal/handler/runs.go +++ b/backend/internal/handler/runs.go @@ -134,6 +134,9 @@ func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) { "branch": run.Branch, "durationMs": time.Since(start).Milliseconds(), }) } + if status == "failed" { + go analyzeFailure(run) + } go notifyLarkRelease(run, status) } diff --git a/backend/internal/handler/users.go b/backend/internal/handler/users.go new file mode 100644 index 0000000..a2edcac --- /dev/null +++ b/backend/internal/handler/users.go @@ -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 连接成功(管理员绑定通过)"}) +} + diff --git a/backend/internal/ldap/ldap.go b/backend/internal/ldap/ldap.go new file mode 100644 index 0000000..eb028f6 --- /dev/null +++ b/backend/internal/ldap/ldap.go @@ -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) +} diff --git a/backend/internal/llm/client.go b/backend/internal/llm/client.go index 4122f4b..e3161fe 100644 --- a/backend/internal/llm/client.go +++ b/backend/internal/llm/client.go @@ -2,6 +2,7 @@ package llm import ( + "bufio" "bytes" "context" "encoding/json" @@ -41,6 +42,7 @@ type chatRequest struct { Messages []chatMessage `json:"messages"` Temperature float64 `json:"temperature"` MaxTokens int `json:"max_tokens"` + Stream bool `json:"stream,omitempty"` } type chatResponse struct { @@ -111,3 +113,108 @@ func truncate(s string, n int) string { } 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 +} diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index af702fc..78c64ec 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -8,6 +8,8 @@ type User struct { Username string `gorm:"uniqueIndex" json:"username"` Password string `json:"-"` // argon2id 哈希,绝不返回给前端 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 CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -79,6 +81,7 @@ type PipelineRun struct { Status string `gorm:"default:pending" json:"status"` // pending|queued|running|success|failed|cancelled DurationMs int64 `json:"durationMs"` BuildNumber int `json:"buildNumber"` + Analysis string `gorm:"type:text" json:"analysis"` // 发布失败时的 AI 分析 ExternalRunID string `json:"externalRunId"` ExternalURL string `json:"externalUrl"` NodesSnapshot string `json:"nodesSnapshot"` @@ -284,6 +287,18 @@ type SystemSetting struct { 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"` UpdatedAt time.Time `json:"updatedAt"` } diff --git a/backend/main.go b/backend/main.go index ba4bfb5..04c8e0c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -27,6 +27,9 @@ func main() { hub := handler.NewHub() r := gin.Default() + // 会话鉴权:所有 /api 需登录(豁免登录/健康检查/入站 Webhook/SSE 日志流) + r.Use(handler.AuthMiddleware()) + // ?_clear=1 时下发 Clear-Site-Data,用于清除浏览器缓存的错误重定向等脏缓存 // (历史 bug:/index.html 曾返回 301 → ./,浏览器永久缓存后反复触发 ERR_TOO_MANY_REDIRECTS) r.Use(func(c *gin.Context) { @@ -50,6 +53,20 @@ func main() { api := r.Group("/api") { api.POST("/auth/login", handler.Login) + api.POST("/auth/logout", func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) }) + + // 用户管理(管理员) + users := api.Group("/users", handler.AdminOnly()) + { + users.GET("", handler.ListUsers) + users.POST("", handler.CreateUser) + users.PUT("/:id", handler.UpdateUser) + users.DELETE("/:id", handler.DeleteUser) + users.POST("/:id/reset-password", handler.ResetPassword) + users.POST("/ldap-import", handler.LdapImport) + } + api.POST("/ldap/users", handler.AdminOnly(), handler.LdapListUsers) + api.POST("/settings/ldap/test", handler.AdminOnly(), handler.TestLDAP) api.GET("/summary", handler.GetSummary) api.GET("/settings", handler.GetSettings) @@ -58,6 +75,7 @@ func main() { api.POST("/settings/test-lark", handler.TestLark) api.POST("/ai/chat", handler.AiChat) + api.POST("/ai/chat/stream", handler.AiChatStream) api.POST("/ai/release", handler.AiRelease(hub)) api.POST("/batch/parse", handler.BatchParse) diff --git a/backend/static/index.html b/backend/static/index.html index fe38bc3..2b7519f 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -3,7 +3,7 @@ FlowStack
-

FlowStack 前端尚未构建

+

FlowStack 发布系统

请在项目根目录执行 ./run.sh build 生成前端产物后重新编译。

diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 396f47e..8ae89af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,9 +22,11 @@ import GitOpsDeploy from '@/pages/GitOpsDeploy'; import PluginManager from '@/pages/PluginManager'; import SystemSettings from '@/pages/SystemSettings'; import AiAssistant from '@/components/ai/AiAssistant'; -import { apiClient } from '@/lib/api'; +import Login from '@/pages/Login'; +import UserManage from '@/pages/UserManage'; +import { apiClient, auth } from '@/lib/api'; -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' | 'webhooks' | 'plugins' | 'users' | 'settings'; export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布'; // 路由 ↔ 模块映射(用户可直接访问 /release、/pipelines/1 等) @@ -41,6 +43,7 @@ const ROUTE_TO_MODULE: Record = { '/jenkins-import': 'jenkins-import', '/webhooks': 'webhooks', '/plugins': 'plugins', + '/users': 'users', '/settings': 'settings', }; @@ -56,12 +59,27 @@ const MODULE_TO_ROUTE: Record = { 'jenkins-import': '/jenkins-import', webhooks: '/webhooks', plugins: '/plugins', + users: '/users', settings: '/settings', }; export default function App() { const location = useLocation(); const navigate = useNavigate(); + const [loggedIn, setLoggedIn] = useState(!!auth.getToken()); + + // 未登录访问任何页面 → 跳登录页 + 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 const [activeModule, setActiveModule] = useState('pipelines'); @@ -215,6 +233,8 @@ export default function App() { return ; case 'plugins': return ; + case 'users': + return ; case 'settings': return ; default: @@ -222,6 +242,10 @@ export default function App() { } }; + if (!loggedIn || location.pathname === '/login') { + return { setLoggedIn(true); navigate('/pipelines', { replace: true }); }} />; + } + return (
@@ -230,6 +254,7 @@ export default function App() { onModuleChange={goToModule} collapsed={sidebarCollapsed} onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)} + onLogout={handleLogout} />
{renderContent()} diff --git a/frontend/src/components/ReleasePanel.tsx b/frontend/src/components/ReleasePanel.tsx index b457a1c..5ea4fe3 100644 --- a/frontend/src/components/ReleasePanel.tsx +++ b/frontend/src/components/ReleasePanel.tsx @@ -1,14 +1,17 @@ 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { apiClient } from '@/lib/api'; +import { apiClient, type PipelineRunDTO } from '@/lib/api'; import { usePipelineLogs } from '@/hooks/usePipelineLogs'; interface Props { pipelineId: number; } +// 列表行:PipelineRunDTO + 失败时自动生成的 AI 分析字段 +type RunRow = PipelineRunDTO & { analysis?: string }; + const statusMap: Record = { success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' }, failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' }, @@ -32,7 +35,7 @@ export default function ReleasePanel({ pipelineId }: Props) { const { data: runs = [], isLoading } = useQuery({ queryKey: ['runs', { pipelineId }], - queryFn: () => apiClient.listRuns({ pipelineId }), + queryFn: () => apiClient.listRuns({ pipelineId }) as Promise, }); const { data: detail } = useQuery({ @@ -132,6 +135,19 @@ export default function ReleasePanel({ pipelineId }: Props) { })}
)} + {/* AI 失败分析(紧凑版,日志区上方) */} + {r.analysis ? ( +
+
+ AI 失败分析 +
+
{r.analysis}
+
+ ) : ['running', 'pending', 'queued'].includes(r.status) ? ( +
+ 失败后将自动生成 AI 分析 +
+ ) : null}
执行日志 diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 756298d..efff48b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -13,18 +13,24 @@ import { Webhook, CloudSync, Puzzle, - Settings + Settings, + Users, + Sun, + Moon, + LogOut, } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api'; +import { useTheme } from '@/providers/theme'; -type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings'; +type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'users' | 'settings'; interface SidebarProps { activeModule: ModuleType; onModuleChange: (module: ModuleType) => void; collapsed: boolean; onToggleCollapse: () => void; + onLogout?: () => void; } const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: typeof GitBranch; badge: string | null }[] }[] = [ @@ -57,18 +63,20 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: label: '系统', items: [ { id: 'plugins', label: '插件市场', icon: Puzzle, badge: '12' }, + { id: 'users', label: '用户管理', icon: Users, badge: null }, { id: 'settings', label: '系统设置', icon: Settings, badge: null }, ], }, ]; -export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse }: SidebarProps) { +export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse, onLogout }: SidebarProps) { // 徽标数来自后端真实统计 const { data: summary } = useQuery({ queryKey: ['summary'], queryFn: apiClient.getSummary, refetchInterval: 15000, }); + const { toggle: themeToggle, isDark } = useTheme(); const badgeFor = (id: ModuleType): string | null => { if (id === 'build-nodes') return summary?.buildNodes != null ? String(summary.buildNodes) : null; @@ -167,11 +175,27 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog ))}
- {/* Collapse toggle */} -
+ +
))} - {sending && ( -
-
- - 思考中… -
-
- )}
{/* 快捷示例 */} @@ -251,14 +391,25 @@ export default function AiAssistant({ onNavigate }: Props) { 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)' }} /> - + {sending ? ( + + ) : ( + + )}
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e475fa5..8bf8656 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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(path: string, init?: RequestInit): Promise { + const headers: Record = { 'Content-Type': 'application/json' } + const token = auth.getToken() + if (token) headers['Authorization'] = `Bearer ${token}` const res = await fetch(path, { - headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) }, + headers: { ...headers, ...(init?.headers as Record || {}) }, ...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) { let message = `请求失败 (HTTP ${res.status})` try { @@ -154,5 +173,17 @@ export const apiClient = { batchParse: (b: { text: string; refType?: string; fixedRef?: string }) => api('/api/batch/parse', { method: 'POST', body: JSON.stringify(b) }), testLLM: () => api('/api/settings/test-llm', { method: 'POST' }), + testLLMDraft: (body: any) => api('/api/settings/test-llm', { method: 'POST', body: JSON.stringify(body) }), testLark: () => api('/api/settings/test-lark', { method: 'POST' }), + // 用户与会话 + login: (username: string, password: string) => + api('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }), + listUsers: () => api('/api/users'), + createUser: (b: any) => api('/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(`/api/users/${id}/reset-password`, { method: 'POST' }), + ldapListUsers: () => api('/api/ldap/users', { method: 'POST' }), + ldapImport: (users: any[]) => api('/api/users/ldap-import', { method: 'POST', body: JSON.stringify({ users }) }), + testLDAP: (body: any) => api('/api/settings/ldap/test', { method: 'POST', body: JSON.stringify(body) }), } diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..d573b11 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { Rocket, Loader2, KeyRound, User } from 'lucide-react'; +import { toast } from 'sonner'; +import { apiClient, auth } from '@/lib/api'; + +export default function Login({ onSuccess }: { onSuccess: () => void }) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + 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); + 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 ( +
+
+
+
+ +
+

FlowStack

+

发布系统 · 请登录

+
+ +
+
+ +
+ + 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" + /> +
+
+
+ +
+ + 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" + /> +
+
+ +
+

+ 支持本地账号与 LDAP 账号(启用后自动识别) +

+
+
+ ); +} diff --git a/frontend/src/pages/ReleaseRecords.tsx b/frontend/src/pages/ReleaseRecords.tsx index 33e7393..ea67104 100644 --- a/frontend/src/pages/ReleaseRecords.tsx +++ b/frontend/src/pages/ReleaseRecords.tsx @@ -3,7 +3,7 @@ import { Search, GitBranch, CheckCircle2, XCircle, Clock, ChevronRight, Terminal, RotateCcw, ExternalLink, Calendar, User, Container, Layers, - Wifi, WifiOff, Loader2 + Wifi, WifiOff, Loader2, Sparkles } from 'lucide-react'; import { toast } from 'sonner'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -54,6 +54,7 @@ interface RunRecord { durationMs?: number; buildNumber?: number; externalUrl?: string; + analysis?: string; // 失败时后端自动生成的 AI 失败分析 } interface RunStage { @@ -144,6 +145,8 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: { const logsToRender: RunLog[] = isRunning ? liveLogs : (detail?.logs ?? []); + const analysis: string | undefined = detail?.run?.analysis ?? record.analysis; + return (
@@ -165,6 +168,19 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: { ))}
+ {/* AI 失败分析(日志区上方) */} + {analysis ? ( +
+
+ AI 失败分析 +
+
{analysis}
+
+ ) : isRunning ? ( +
+ 失败后将自动生成 AI 分析 +
+ ) : null} {activeTab === 'stages' && ( detailLoading && stages.length === 0 ? (
diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx index ecc1bb2..c49b0fa 100644 --- a/frontend/src/pages/SystemSettings.tsx +++ b/frontend/src/pages/SystemSettings.tsx @@ -1,11 +1,12 @@ import { useEffect, useState } from 'react'; import { Palette, Monitor, Moon, Sun, - Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2 + Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2, ExternalLink, ChevronDown, ChevronRight } from 'lucide-react'; import { toast } from 'sonner'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '@/lib/api'; +import { useTheme, type ThemeMode } from '@/providers/theme'; interface ThemeConfig { mode: 'light' | 'dark' | 'system'; @@ -62,12 +63,48 @@ const colorOptions = [ { name: '青色', value: '#0e7c7b', class: 'bg-[#0e7c7b]' }, ]; +// LLM 服务商预设(OpenAI 兼容接口) +const LLM_PROVIDERS = [ + { id: 'deepseek', label: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', model: 'deepseek-chat', keyUrl: 'https://platform.deepseek.com/api_keys' }, + { id: 'glm', label: '智谱 GLM', endpoint: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.7', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys' }, + { 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' }, + { id: 'qwen', label: '阿里 Qwen', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', keyUrl: 'https://bailian.console.aliyun.com/' }, + { 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() { const queryClient = useQueryClient(); + const { change: themeChange } = useTheme(); const [theme, setTheme] = useState(defaultTheme); const [notifications, setNotifications] = useState(defaultNotifications); const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' }); + const [llmProvider, setLlmProvider] = useState('custom'); 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 }); @@ -83,12 +120,27 @@ export default function SystemSettings() { model: settings.llmModel || '', 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]); const saveLarkMutation = useMutation({ @@ -114,6 +166,54 @@ export default function SystemSettings() { 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({ mutationFn: () => { const body: any = { llmEnabled: llm.enabled, llmEndpoint: llm.endpoint, llmModel: llm.model }; @@ -129,7 +229,13 @@ export default function SystemSettings() { }); const testLlmMutation = useMutation({ - mutationFn: apiClient.testLLM, + // 草稿直测:用当前表单值(无需先保存) + mutationFn: () => apiClient.testLLMDraft({ + llmEnabled: true, + llmEndpoint: llm.endpoint, + llmModel: llm.model, + llmApiKey: llm.apiKey || undefined, + }), onSuccess: (res) => { if (res.success) toast.success('AI 连接成功', { description: res.message }); else toast.error('AI 连接失败', { description: res.message }); @@ -191,14 +297,14 @@ export default function SystemSettings() {
{/* Mode */}
- +
{[ { id: 'light', label: '浅色', icon: Sun }, { id: 'dark', label: '深色', icon: Moon }, { id: 'system', label: '跟随系统', icon: Monitor }, ].map(m => ( -
@@ -452,6 +593,106 @@ export default function SystemSettings() {
+ + {/* LDAP 目录服务 */} +
+
+
+ LDAP 目录服务 + + {settings?.ldapEnabled ? '已启用' : '未启用'} + +
+
+ {!ldap.enabled ? ( +
+

+ LDAP 未启用——打开开关填写目录服务信息,启用后可在用户管理页导入用户 +

+ +
+ ) : ( +
+
+
+ + 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)' }} /> +
+
+ + 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)' }} /> +
+
+ +
+ + 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)' }} /> +
+
+ + 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)' }} /> +
+
+ + 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)' }} /> +
+
+ + 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)' }} /> +
+
+
+ + 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)' }} /> +
+
+ + 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)' }} /> +
+
+ + 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)' }} /> +
+
+ {/* 可选:测试账号级验证 */} +
+ + {ldapTestOpen && ( +
+
+ + 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)' }} /> +
+
+ + 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)' }} /> +
+
+ )} +
+

+ 「测试连接」按当前表单草稿直测(Bind 密码留空时使用已保存密码),无需先保存。保存后可在「用户管理」页从 LDAP 批量导入用户。 +

+
+ + +
+
+ )} +
); diff --git a/frontend/src/pages/UserManage.tsx b/frontend/src/pages/UserManage.tsx new file mode 100644 index 0000000..54bf779 --- /dev/null +++ b/frontend/src/pages/UserManage.tsx @@ -0,0 +1,623 @@ +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 } 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(emptyForm); + const [createdResult, setCreatedResult] = useState<{ username: string; password: string } | null>(null); + const [ldapOpen, setLdapOpen] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [importResult, setImportResult] = useState(null); + + 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 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(); + 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 ( +
+
+
+

用户管理

+ + {users.length} + +
+
+ {ldapReady && ( + + )} + +
+
+ +
+ {usersQuery.isLoading ? ( +
+ + 加载中... +
+ ) : usersQuery.isError ? ( +
+
{(usersQuery.error as Error).message}
+ +
+ ) : ( +
+ + + + {['用户名', '昵称', '邮箱', '角色', '来源', '创建时间', '操作'].map((h) => ( + + ))} + + + + {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 ( + + + + + + + + + + ); + })} + {users.length === 0 && ( + + + + )} + +
+ {h} +
+ {u.username} + {u.nickname || '—'}{u.email || '—'} + + {role.label} + + + + {source.label} + + {fmtTime(u.createdAt)} +
+ {u.authSource !== 'ldap' && ( + + )} + + +
+
+ 暂无用户,点击右上角「新建用户」创建 +
+
+ )} +
+ + {/* 新建用户弹窗 */} + + + + 新建用户 + +
+
+ + setForm({ ...form, username: e.target.value })} + placeholder="zhangsan" + /> +
+
+
+ + setForm({ ...form, nickname: e.target.value })} + placeholder="张三" + /> +
+
+ + +
+
+
+ + setForm({ ...form, email: e.target.value })} + placeholder="zhangsan@example.com" + /> +
+
+ + setForm({ ...form, password: e.target.value })} + placeholder="留空由系统随机生成" + /> +
+
+ + + + +
+
+ + {/* 创建成功:一次性密码 */} + !open && setCreatedResult(null)}> + + + 用户已创建 + +
+

+ 用户 {createdResult?.username} 创建成功。 + 系统已生成一次性初始密码,仅显示此次,请立即复制并妥善交付给用户: +

+
+ + {createdResult?.password} + + +
+
+ + + +
+
+ + {/* LDAP 导入弹窗 */} + + + + 从 LDAP 导入用户 + + + {importResult ? ( + /* 结果态:四色统计 + 失败名单 */ +
+
+ {importStats.map((s) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ {importResult.failedUsers.length > 0 && ( +
+
+ + 以下用户导入失败: +
+
+ {importResult.failedUsers.map((name) => ( + + {name} + + ))} +
+
+ )} +

+ 「转换」表示本地同名账号已切换为 LDAP 来源;「跳过」表示该用户已是 LDAP 账号。 +

+
+ ) : ldapQuery.isLoading ? ( +
+ + 正在从 LDAP 目录读取用户... +
+ ) : ldapQuery.isError ? ( +
+
+ {(ldapQuery.error as Error).message} +
+
请到系统设置检查 LDAP 配置
+ +
+ ) : ( + /* 选择态 */ +
+
+ {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 ( + + ); + })} + {ldapEntries.length === 0 && ( +
+ 目录中未读取到用户 +
+ )} +
+
+ + 共 {ldapEntries.length} 个用户,已选 {selected.size} 个 + + +
+
+ )} + + {importResult && ( + + + + )} +
+
+
+ ); +} diff --git a/frontend/src/providers/theme.tsx b/frontend/src/providers/theme.tsx new file mode 100644 index 0000000..7aed307 --- /dev/null +++ b/frontend/src/providers/theme.tsx @@ -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(() => { + 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 }; +}