diff --git a/backend/docs/API.md b/backend/docs/API.md new file mode 100644 index 0000000..6a4d486 --- /dev/null +++ b/backend/docs/API.md @@ -0,0 +1,140 @@ +# FlowStack 接口文档 + +> 与当前后端代码对齐;所有 `/api/*` 需携带登录 Token,敏感字段只写不回显。 + +| | | +|---|---| +| 文档版本 | v0.4 | +| Base URL | `http://:9527` | +| 鉴权 | `Authorization: Bearer `(`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` 最近运行) | +| `/api/pipelines` | POST | 创建(name 必填;deployType: host/kubernetes 值为 `k8s`/gitops) | +| `/api/pipelines/:id` | GET | 详情(pipeline + nodes + edges + lastRun) | +| `/api/pipelines/:id` | PUT | 更新(白名单字段,含触发器/变量/缓存) | +| `/api/pipelines/:id` | DELETE | 删除(级联删节点连线) | +| `/api/pipelines/:id/nodes` | PUT | 全量保存画布节点 | +| `/api/pipelines/:id/edges` | PUT | 全量保存画布连线 | +| `/api/pipelines/:id/runs` | POST | 触发运行(body 可带 branch/env/triggerType) | + +`type=jenkins` 且已关联服务器的流水线触发时走真实 Jenkins 引擎(队列→构建→增量日志回传)。 + +## 运行记录 + +| 接口 | 方法 | 说明 | +|---|---|---| +| `/api/runs` | GET | 运行列表(`?pipelineId=&status=`) | +| `/api/runs/:runId` | GET | 详情(run + stages + logs;失败含 AI `analysis`) | +| `/api/runs/:runId/logs/stream` | GET | SSE 实时日志(免鉴权;事件 connected/log,历史先回放) | +| `/api/runs/:runId/retry` | POST | 以同分支重新触发 | + +## 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`)。 diff --git a/backend/docs/GUIDE.md b/backend/docs/GUIDE.md new file mode 100644 index 0000000..a0c26ba --- /dev/null +++ b/backend/docs/GUIDE.md @@ -0,0 +1,130 @@ +# 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 通知 + +**是什么**:发布成功/失败自动向群推送结果卡片(应用/分支/构建/状态/时间 + 「查看构建详情」按钮直达 Jenkins)。 + +**怎么用**:系统设置 → 消息通知 → 填群机器人 Webhook(和可选的签名密钥)→「发送测试卡片」验证。 + +**技巧**:单条流水线可在「基本信息」里单独填通知 Webhook,优先于全局配置。 + +## 用户与 LDAP + +**是什么**:本地账号 + 企业目录(LDAP/AD)账号统一登录。 + +**怎么用**: +- 系统设置 → 用户目录:启用 LDAP,填服务器/Bind DN/Base DN → 测试连接(可带测试账号验证)→ 保存 +- 用户管理页出现「从 LDAP 导入」→ 勾选导入;列表来源徽章区分「本地账号」和「LDAP 导入」 + +**技巧**: +- LDAP 用户密码在目录中管理(不落库),首登自动建号 +- 未启用 LDAP 时导入按钮自动隐藏 + +## 环境与凭证 + +**是什么**:环境管理登记主机与 Kubernetes 集群(真实连通测试);凭证集中保管敏感信息。 + +**技巧**: +- 凭证内容 AES 加密存储、只写不回显,编辑留空=不修改 +- SSH 凭证关联构建节点后即可远程磁盘测试 + +## 常见问题 + +**初始密码在哪**:服务器 `data/initial-admin-password.txt` 或 `grep -A6 "已生成管理员账号" logs/flowstack.log` + +**默认端口**:9527,可用 `PORT=xxxx ./run.sh` 覆盖 + +**页面打不开/跳转循环**:访问一次 `http://<地址>/?_clear=1` 清除浏览器缓存的旧重定向 + +**深色模式**:侧边栏底部太阳/月亮按钮,或系统设置 → 通用 → 主题模式(跟随系统可选) + +**API 调用**:见「接口文档」页——所有 `/api/*` 需带登录 token(`Authorization: Bearer `) diff --git a/backend/internal/handler/docs.go b/backend/internal/handler/docs.go new file mode 100644 index 0000000..d6e616a --- /dev/null +++ b/backend/internal/handler/docs.go @@ -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"), + }) + } +} diff --git a/backend/main.go b/backend/main.go index 04c8e0c..10a0a75 100644 --- a/backend/main.go +++ b/backend/main.go @@ -19,6 +19,11 @@ import ( //go:embed all:static var staticFS embed.FS +// 使用指南与接口文档(运行目录 docs/ 优先) +// +//go:embed docs/GUIDE.md docs/API.md +var docsFS embed.FS + func main() { if err := database.Init(); err != nil { panic("数据库初始化失败: " + err.Error()) @@ -67,6 +72,7 @@ func main() { } api.POST("/ldap/users", handler.AdminOnly(), handler.LdapListUsers) api.POST("/settings/ldap/test", handler.AdminOnly(), handler.TestLDAP) + api.GET("/docs", handler.GetDocs(docsFS)) api.GET("/summary", handler.GetSummary) api.GET("/settings", handler.GetSettings) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 09b90b3..79cb29a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -44,6 +44,8 @@ "embla-carousel-react": "^8.6.0", "input-otp": "^1.5.0", "lucide-react": "^0.562.0", + "marked": "^18.1.2", + "marked-gfm-heading-id": "^4.1.4", "next-themes": "^0.4.6", "react": "^19.3.1", "react-day-picker": "^9.14.0", @@ -3650,23 +3652,6 @@ "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": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4737,23 +4722,6 @@ "dev": true, "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": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", @@ -4886,6 +4854,12 @@ "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": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -5136,13 +5110,6 @@ "dev": true, "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": { "version": "1.0.1", "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" } }, + "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": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6021,16 +6012,6 @@ "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": { "version": "1.22.13", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0be7fbe..59ffac9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,6 +48,8 @@ "embla-carousel-react": "^8.6.0", "input-otp": "^1.5.0", "lucide-react": "^0.562.0", + "marked": "^18.1.2", + "marked-gfm-heading-id": "^4.1.4", "next-themes": "^0.4.6", "react": "^19.3.1", "react-day-picker": "^9.14.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ae89af..137ffe8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,9 +24,10 @@ import SystemSettings from '@/pages/SystemSettings'; import AiAssistant from '@/components/ai/AiAssistant'; import Login from '@/pages/Login'; import UserManage from '@/pages/UserManage'; +import Docs from '@/pages/Docs'; import { apiClient, auth } from '@/lib/api'; -export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'users' | 'settings'; +export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'users' | 'docs' | 'settings'; export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布'; // 路由 ↔ 模块映射(用户可直接访问 /release、/pipelines/1 等) @@ -44,6 +45,7 @@ const ROUTE_TO_MODULE: Record = { '/webhooks': 'webhooks', '/plugins': 'plugins', '/users': 'users', + '/docs': 'docs', '/settings': 'settings', }; @@ -60,6 +62,7 @@ const MODULE_TO_ROUTE: Record = { webhooks: '/webhooks', plugins: '/plugins', users: '/users', + docs: '/docs', settings: '/settings', }; @@ -235,6 +238,8 @@ export default function App() { return ; case 'users': return ; + case 'docs': + return ; case 'settings': return ; default: diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index efff48b..e3be8d4 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -14,6 +14,7 @@ import { CloudSync, Puzzle, Settings, + BookOpen, Users, Sun, Moon, @@ -23,7 +24,7 @@ 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' | 'users' | 'settings'; +type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'users' | 'docs' | 'settings'; interface SidebarProps { activeModule: ModuleType; @@ -62,6 +63,7 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: { label: '系统', items: [ + { id: 'docs', label: '文档', icon: BookOpen, badge: null }, { id: 'plugins', label: '插件市场', icon: Puzzle, badge: '12' }, { id: 'users', label: '用户管理', icon: Users, badge: null }, { id: 'settings', label: '系统设置', icon: Settings, badge: null }, diff --git a/frontend/src/index.css b/frontend/src/index.css index f0904af..f6258dd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -351,3 +351,25 @@ textarea:focus { .context-menu-item-danger:hover { 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; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8bf8656..bacc142 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -186,4 +186,5 @@ export const apiClient = { 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) }), + getDocs: () => api('/api/docs'), } diff --git a/frontend/src/pages/Docs.tsx b/frontend/src/pages/Docs.tsx new file mode 100644 index 0000000..4a8ea6c --- /dev/null +++ b/frontend/src/pages/Docs.tsx @@ -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 = { + 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('guide'); + + const { data, isLoading, refetch, isRefetching } = useQuery({ + queryKey: ['docs'], + queryFn: () => api('/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 ( +
+ {/* Header */} +
+
+ +

文档

+ {/* 指南/接口 切换 */} +
+ {(['guide', 'api'] as DocKind[]).map((k) => ( + + ))} +
+
+ +
+ +
+ {/* 左侧目录 */} +
+
目录
+ {isLoading ? ( +
加载中…
+ ) : ( + toc.map((t) => ( + + {t.text} + + )) + )} +
+ + {/* 右侧内容 */} +
+ {isLoading ? ( +
+ 加载文档中… +
+ ) : ( +
+
+

{meta[kind].title}

+

{meta[kind].subtitle}

+
+
+
+ )} +
+
+
+ ); +}