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:
1 parent
8ee08e08da
commit
ed88a18e03
11 files changed
+480
-53
No files matched your search
@@ -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`)。
|
||||||
@@ -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>`)
|
||||||
@@ -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"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ import (
|
|||||||
//go:embed all:static
|
//go:embed all:static
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
// 使用指南与接口文档(运行目录 docs/ 优先)
|
||||||
|
//
|
||||||
|
//go:embed docs/GUIDE.md docs/API.md
|
||||||
|
var docsFS embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := database.Init(); err != nil {
|
if err := database.Init(); err != nil {
|
||||||
panic("数据库初始化失败: " + err.Error())
|
panic("数据库初始化失败: " + err.Error())
|
||||||
@@ -67,6 +72,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
api.POST("/ldap/users", handler.AdminOnly(), handler.LdapListUsers)
|
api.POST("/ldap/users", handler.AdminOnly(), handler.LdapListUsers)
|
||||||
api.POST("/settings/ldap/test", handler.AdminOnly(), handler.TestLDAP)
|
api.POST("/settings/ldap/test", handler.AdminOnly(), handler.TestLDAP)
|
||||||
|
api.GET("/docs", handler.GetDocs(docsFS))
|
||||||
|
|
||||||
api.GET("/summary", handler.GetSummary)
|
api.GET("/summary", handler.GetSummary)
|
||||||
api.GET("/settings", handler.GetSettings)
|
api.GET("/settings", handler.GetSettings)
|
||||||
|
|||||||
Generated
+32
-51
@@ -44,6 +44,8 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.5.0",
|
"input-otp": "^1.5.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"marked": "^18.1.2",
|
||||||
|
"marked-gfm-heading-id": "^4.1.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
@@ -3650,23 +3652,6 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
|
||||||
"version": "8.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
|
||||||
"extraneous": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"fast-uri": "^3.0.1",
|
|
||||||
"json-schema-traverse": "^1.0.0",
|
|
||||||
"require-from-string": "^2.0.2"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/epoberezkin"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -4737,23 +4722,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
|
||||||
"version": "3.1.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
|
||||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
|
||||||
"extraneous": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/fastify"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fastify"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
|
||||||
@@ -4886,6 +4854,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/github-slugger": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -5136,13 +5110,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
|
||||||
"extraneous": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
@@ -5267,6 +5234,30 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "18.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.2.tgz",
|
||||||
|
"integrity": "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/marked-gfm-heading-id": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked-gfm-heading-id/-/marked-gfm-heading-id-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-CspnvVfHSkb/znqdPS4jUR8HtCjq3M/DnrsJCrfLBLvdrgbemmoINKpeWKQYkBiXAoBGejw0cV7xzqrPdup3WA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"github-slugger": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"marked": ">=13 <19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -6021,16 +6012,6 @@
|
|||||||
"decimal.js-light": "^2.4.1"
|
"decimal.js-light": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/require-from-string": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
|
||||||
"extraneous": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.13",
|
"version": "1.22.13",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz",
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.5.0",
|
"input-otp": "^1.5.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"marked": "^18.1.2",
|
||||||
|
"marked-gfm-heading-id": "^4.1.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ import SystemSettings from '@/pages/SystemSettings';
|
|||||||
import AiAssistant from '@/components/ai/AiAssistant';
|
import AiAssistant from '@/components/ai/AiAssistant';
|
||||||
import Login from '@/pages/Login';
|
import Login from '@/pages/Login';
|
||||||
import UserManage from '@/pages/UserManage';
|
import UserManage from '@/pages/UserManage';
|
||||||
|
import Docs from '@/pages/Docs';
|
||||||
import { apiClient, auth } from '@/lib/api';
|
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 = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
||||||
|
|
||||||
// 路由 ↔ 模块映射(用户可直接访问 /release、/pipelines/1 等)
|
// 路由 ↔ 模块映射(用户可直接访问 /release、/pipelines/1 等)
|
||||||
@@ -44,6 +45,7 @@ const ROUTE_TO_MODULE: Record<string, ModuleType> = {
|
|||||||
'/webhooks': 'webhooks',
|
'/webhooks': 'webhooks',
|
||||||
'/plugins': 'plugins',
|
'/plugins': 'plugins',
|
||||||
'/users': 'users',
|
'/users': 'users',
|
||||||
|
'/docs': 'docs',
|
||||||
'/settings': 'settings',
|
'/settings': 'settings',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ const MODULE_TO_ROUTE: Record<ModuleType, string> = {
|
|||||||
webhooks: '/webhooks',
|
webhooks: '/webhooks',
|
||||||
plugins: '/plugins',
|
plugins: '/plugins',
|
||||||
users: '/users',
|
users: '/users',
|
||||||
|
docs: '/docs',
|
||||||
settings: '/settings',
|
settings: '/settings',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -235,6 +238,8 @@ export default function App() {
|
|||||||
return <PluginManager />;
|
return <PluginManager />;
|
||||||
case 'users':
|
case 'users':
|
||||||
return <UserManage />;
|
return <UserManage />;
|
||||||
|
case 'docs':
|
||||||
|
return <Docs />;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <SystemSettings />;
|
return <SystemSettings />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
CloudSync,
|
CloudSync,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Settings,
|
Settings,
|
||||||
|
BookOpen,
|
||||||
Users,
|
Users,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -23,7 +24,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { apiClient } from '@/lib/api';
|
import { apiClient } from '@/lib/api';
|
||||||
import { useTheme } from '@/providers/theme';
|
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 {
|
interface SidebarProps {
|
||||||
activeModule: ModuleType;
|
activeModule: ModuleType;
|
||||||
@@ -62,6 +63,7 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon:
|
|||||||
{
|
{
|
||||||
label: '系统',
|
label: '系统',
|
||||||
items: [
|
items: [
|
||||||
|
{ id: 'docs', label: '文档', icon: BookOpen, badge: null },
|
||||||
{ id: 'plugins', label: '插件市场', icon: Puzzle, badge: '12' },
|
{ id: 'plugins', label: '插件市场', icon: Puzzle, badge: '12' },
|
||||||
{ id: 'users', label: '用户管理', icon: Users, badge: null },
|
{ id: 'users', label: '用户管理', icon: Users, badge: null },
|
||||||
{ id: 'settings', label: '系统设置', icon: Settings, badge: null },
|
{ id: 'settings', label: '系统设置', icon: Settings, badge: null },
|
||||||
|
|||||||
@@ -351,3 +351,25 @@ textarea:focus {
|
|||||||
.context-menu-item-danger:hover {
|
.context-menu-item-danger:hover {
|
||||||
background: rgba(207, 34, 46, 0.08);
|
background: rgba(207, 34, 46, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 文档页 Markdown 排版 ---------- */
|
||||||
|
.md-body { font-size: 13px; line-height: 1.75; color: var(--text-primary); }
|
||||||
|
.md-body h1 { font-size: 20px; font-weight: 700; margin: 24px 0 12px; }
|
||||||
|
.md-body h2 { font-size: 16px; font-weight: 700; margin: 22px 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border-color-light); scroll-margin-top: 16px; }
|
||||||
|
.md-body h3 { font-size: 14px; font-weight: 600; margin: 16px 0 8px; scroll-margin-top: 16px; }
|
||||||
|
.md-body h4 { font-size: 13px; font-weight: 600; margin: 12px 0 6px; }
|
||||||
|
.md-body p { margin: 8px 0; color: var(--text-secondary); }
|
||||||
|
.md-body strong { color: var(--text-primary); font-weight: 600; }
|
||||||
|
.md-body ul, .md-body ol { margin: 8px 0; padding-left: 22px; color: var(--text-secondary); }
|
||||||
|
.md-body li { margin: 4px 0; }
|
||||||
|
.md-body a { color: var(--accent-blue); text-decoration: none; }
|
||||||
|
.md-body a:hover { text-decoration: underline; }
|
||||||
|
.md-body blockquote { margin: 10px 0; padding: 8px 14px; border-left: 3px solid var(--accent-blue); background: var(--panel-bg-secondary); border-radius: 0 6px 6px 0; color: var(--text-secondary); }
|
||||||
|
.md-body code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; background: var(--panel-bg-secondary); border: 1px solid var(--border-color-light); border-radius: 4px; padding: 1px 5px; color: var(--accent-purple); }
|
||||||
|
.md-body pre { margin: 10px 0; padding: 12px 14px; background: var(--panel-bg-secondary); border: 1px solid var(--border-color-light); border-radius: 8px; overflow-x: auto; }
|
||||||
|
.md-body pre code { background: transparent; border: none; padding: 0; color: var(--text-primary); font-size: 11.5px; line-height: 1.6; }
|
||||||
|
.md-body table { width: 100%; margin: 10px 0; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.md-body th { text-align: left; padding: 7px 10px; background: var(--panel-bg-secondary); color: var(--text-tertiary); font-weight: 600; border: 1px solid var(--border-color-light); white-space: nowrap; }
|
||||||
|
.md-body td { padding: 6px 10px; border: 1px solid var(--border-color-light); color: var(--text-secondary); vertical-align: top; }
|
||||||
|
.md-body tr:nth-child(even) td { background: color-mix(in srgb, var(--panel-bg-secondary) 45%, transparent); }
|
||||||
|
.md-body hr { border: none; border-top: 1px solid var(--border-color-light); margin: 18px 0; }
|
||||||
@@ -186,4 +186,5 @@ export const apiClient = {
|
|||||||
ldapListUsers: () => api<any[]>('/api/ldap/users', { method: 'POST' }),
|
ldapListUsers: () => api<any[]>('/api/ldap/users', { method: 'POST' }),
|
||||||
ldapImport: (users: any[]) => api<any>('/api/users/ldap-import', { method: 'POST', body: JSON.stringify({ users }) }),
|
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) }),
|
testLDAP: (body: any) => api<any>('/api/settings/ldap/test', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
getDocs: () => api<any>('/api/docs'),
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user