Files
FlowStack/frontend/src/pages/Docs.tsx
T
Logan.Li ed88a18e03 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,深浅主题适配排版)
- 侧边栏系统组新增「文档」入口(第一位)
2026-10-27 14:45:33 +08:00

108 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}