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,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