feat: 新增「文档」菜单(使用指南 + 接口文档)

- backend/docs/GUIDE.md:使用指南,每功能一句话讲清
  「是什么→怎么用→实用技巧」(快速开始 3 步、流水线快速上手配置、
  GitOps 概念与用法、AI 助手/批量发布/Jenkins/构建节点/工具/
  飞书通知/用户 LDAP/常见问题 FAQ)
- backend/docs/API.md:接口文档(opsx-sec 骨架:通用约定/模块
  索引表/重点端点 curl 示例/状态码速查)
- GET /api/docs:文档 embed 进二进制,运行目录 docs/ 优先可热更新
- /docs 页面:使用指南/接口文档切换 + 左侧目录锚点导航 +
  markdown 渲染(marked + gfm-heading-id,深浅主题适配排版)
- 侧边栏系统组新增「文档」入口(第一位)
This commit is contained in:
Logan.Li committed 2026-10-27 14:45:33 +08:00
1 parent 8ee08e08da
commit ed88a18e03
11 files changed
+480 -53

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
# FlowStack 接口文档
> 与当前后端代码对齐;所有 `/api/*` 需携带登录 Token,敏感字段只写不回显。
| | |
|---|---|
| 文档版本 | v0.4 |
| Base URL | `http://<host>:9527` |
| 鉴权 | `Authorization: Bearer <token>`(`POST /api/auth/login` 获取,7 天有效) |
| 技术栈 | Go gin + SQLite(GORM) |
**典型接入流程**:登录拿 token → 管理流水线/节点/凭证 → 触发运行 → SSE 看日志 → 查发布记录。
## 通用约定
- **鉴权**:除登录、健康检查(`/health`)、入站 Webhook(`/api/hooks/*`)、SSE 日志流外,所有接口需 Bearer Token;用户管理类接口需 admin 角色
- **错误格式**:`{"error": "中文错误信息"}`,HTTP 状态码语义化(400 参数错 / 401 未登录 / 403 权限 / 404 不存在 / 409 冲突)
- **敏感字段只写不回显**:凭证内容、API Key、Token、Bind 密码等提交后永不返回,响应以 `xxxSet` 布尔表示已配置;**编辑时留空 = 保持原值**
- **时间**:ISO 8601 带时区
## 认证
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/auth/login` | POST | 登录(本地 argon2id 或 LDAP 直连验证),返回签名 token |
```bash
curl -X POST http://localhost:9527/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"xxx"}'
# → {"success":true,"token":"...","username":"admin","role":"admin","expiresAt":"..."}
```
LDAP 用户名在目录验证通过后自动建号/同步(JIT);错误统一返回 401「用户名或密码错误」。
## 流水线
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/pipelines` | GET | 列表(每条附 `lastRun` 最近运行) |
| `/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`)。
+130
View File
@@ -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 <token>`)
+31
View File
@@ -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"),
})
}
}
+6
View File
@@ -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)
+32 -51
View File
@@ -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",
+2
View File
@@ -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",
+6 -1
View File
@@ -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<string, ModuleType> = {
'/webhooks': 'webhooks',
'/plugins': 'plugins',
'/users': 'users',
'/docs': 'docs',
'/settings': 'settings',
};
@@ -60,6 +62,7 @@ const MODULE_TO_ROUTE: Record<ModuleType, string> = {
webhooks: '/webhooks',
plugins: '/plugins',
users: '/users',
docs: '/docs',
settings: '/settings',
};
@@ -235,6 +238,8 @@ export default function App() {
return <PluginManager />;
case 'users':
return <UserManage />;
case 'docs':
return <Docs />;
case 'settings':
return <SystemSettings />;
default:
+3 -1
View File
@@ -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 },
+22
View File
@@ -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; }
+1
View File
@@ -186,4 +186,5 @@ export const apiClient = {
ldapListUsers: () => api<any[]>('/api/ldap/users', { method: 'POST' }),
ldapImport: (users: any[]) => api<any>('/api/users/ldap-import', { method: 'POST', body: JSON.stringify({ users }) }),
testLDAP: (body: any) => api<any>('/api/settings/ldap/test', { method: 'POST', body: JSON.stringify(body) }),
getDocs: () => api<any>('/api/docs'),
}
+107
View File
@@ -0,0 +1,107 @@
import { useMemo, useState } from 'react';
import { BookOpen, RefreshCw, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Marked } from 'marked';
import { gfmHeadingId } from 'marked-gfm-heading-id';
import { api } from '@/lib/api';
type DocKind = 'guide' | 'api';
const meta: Record<DocKind, { title: string; subtitle: string }> = {
guide: { title: '使用指南', subtitle: '每个功能一句话讲清:是什么 → 怎么用 → 实用技巧' },
api: { title: '接口文档', subtitle: '与当前后端代码对齐;调用需登录 Token,敏感字段只写不回显' },
};
// 从 Markdown 提取 ## 级标题做目录(marked-gfm-heading-id 生成锚点 id)
function extractToc(md: string): { text: string; id: string; level: number }[] {
const out: { text: string; id: string; level: number }[] = [];
for (const line of md.split('\n')) {
const m = /^(#{2,3})\s+(.+)$/.exec(line.trim());
if (!m) continue;
const text = m[2].trim();
out.push({ text, id: headingId(text), level: m[1].length });
}
return out;
}
// 与 marked-gfm-heading-id 的默认 slug 规则保持一致(小写、去标点、空格转 -)
function headingId(text: string): string {
return text.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, '').trim().replace(/\s+/g, '-');
}
export default function Docs() {
const [kind, setKind] = useState<DocKind>('guide');
const { data, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['docs'],
queryFn: () => api<any>('/api/docs'),
});
const raw = (kind === 'guide' ? data?.guide : data?.api) || '';
const html = useMemo(() => {
const marked = new Marked(gfmHeadingId());
return marked.parse(raw, { async: false }) as string;
}, [raw]);
const toc = useMemo(() => extractToc(raw), [raw]);
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3">
<BookOpen className="w-4.5 h-4.5" style={{ color: 'var(--accent-blue)' }} />
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>文档</h1>
{/* 指南/接口 切换 */}
<div className="flex items-center rounded-md border overflow-hidden ml-2" style={{ borderColor: 'var(--border-color-light)' }}>
{(['guide', 'api'] as DocKind[]).map((k) => (
<button key={k} onClick={() => setKind(k)} className="px-3 py-[3px] text-[11px] font-medium"
style={{ background: kind === k ? 'var(--accent-blue)' : 'transparent', color: kind === k ? '#fff' : 'var(--text-secondary)' }}>
{meta[k].title}
</button>
))}
</div>
</div>
<button onClick={() => refetch()} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RefreshCw className={`w-3.5 h-3.5 ${isRefetching ? 'animate-spin' : ''}`} />刷新
</button>
</div>
<div className="flex-1 flex overflow-hidden">
{/* 左侧目录 */}
<div className="w-56 border-r overflow-y-auto custom-scrollbar flex-shrink-0 py-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 pb-2 text-[11px] font-semibold" style={{ color: 'var(--text-tertiary)' }}>目录</div>
{isLoading ? (
<div className="px-4 py-4 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>
) : (
toc.map((t) => (
<a key={t.id + t.text} href={`#${t.id}`}
className={`block truncate hover:bg-black/[0.03] ${t.level === 3 ? 'pl-7' : 'pl-4'} pr-3 py-[5px] text-[12px]`}
style={{ color: t.level === 3 ? 'var(--text-tertiary)' : 'var(--text-secondary)' }}>
{t.text}
</a>
))
)}
</div>
{/* 右侧内容 */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-20 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-4 h-4 animate-spin" />加载文档中…
</div>
) : (
<div className="max-w-3xl mx-auto px-8 py-6">
<div className="mb-4 pb-4 border-b" style={{ borderColor: 'var(--border-color)' }}>
<h2 className="text-[17px] font-bold" style={{ color: 'var(--text-primary)' }}>{meta[kind].title}</h2>
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>{meta[kind].subtitle}</p>
</div>
<div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />
</div>
)}
</div>
</div>
</div>
);
}