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

+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>
);
}