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

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