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
@@ -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:
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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; }
|
||||
@@ -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'),
|
||||
}
|
||||
@@ -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