Files
planet/frontend/src/pages/Docs/Docs.tsx
rayd1o eb4c4b7904
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.66.2
2026-05-26 08:45:33 +08:00

528 lines
20 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import axios from 'axios'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import SegmentedControl from '../../components/SegmentedControl/SegmentedControl'
import { useAuthStore } from '../../stores/auth'
import {
createHeadingIdResolver,
defaultDocsSlug,
extractHeadings,
getDocsEntries,
getDocsEntry,
getDocsGroupLabel,
groupDocsEntries,
slugFromDocsHref,
} from './docs-content'
import type { DocsCatalogItem, DocsEntry, DocsHeading, DocsLang } from './docs-content'
import { buildDocsSearchRecords, searchDocs } from './docs-search'
import type { DocsSearchRecord } from './docs-search'
import './Docs.css'
type DocsThemeMode = 'system' | 'light' | 'dark'
type DocsErrorState = 'none' | 'unauthenticated' | 'forbidden' | 'not_found' | 'load_failed'
interface DocsCatalogResponse {
items: DocsCatalogItem[]
authenticated: boolean
}
interface DocsContentResponse extends DocsEntry {
lang: DocsLang
markdown: string
}
const MIN_TOC_HEADING_LEVEL = 2
const MAX_TOC_HEADING_LEVEL = 3
const ACTIVE_HEADING_TOP_OFFSET_PX = 72
const TOC_SCROLL_OFFSET_PX = 24
const FOOTER_CONTROL_SCALE = 0.86
function getHashFromHref(href: string): string {
const hashIndex = href.indexOf('#')
return hashIndex >= 0 ? href.slice(hashIndex) : ''
}
function readStoredLang(): DocsLang {
const stored = localStorage.getItem('docs-lang')
return stored === 'en' ? 'en' : 'zh'
}
function readStoredThemeMode(): DocsThemeMode {
const stored = localStorage.getItem('docs-theme')
if (stored === 'system' || stored === 'light' || stored === 'dark') {
return stored
}
return 'system'
}
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return 'light'
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export default function Docs() {
const { slug } = useParams()
const navigate = useNavigate()
const { token } = useAuthStore()
const [lang, setLang] = useState<DocsLang>(readStoredLang)
const [themeMode, setThemeMode] = useState<DocsThemeMode>(readStoredThemeMode)
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme)
const [catalogItems, setCatalogItems] = useState<DocsCatalogItem[]>([])
const [isCatalogLoading, setIsCatalogLoading] = useState(true)
const [markdown, setMarkdown] = useState('')
const [activeContentEntry, setActiveContentEntry] = useState<DocsEntry | null>(null)
const [docError, setDocError] = useState<DocsErrorState>('none')
const [isLoading, setIsLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [searchRecords, setSearchRecords] = useState<DocsSearchRecord[]>([])
const [activeHeadingId, setActiveHeadingId] = useState<string>('')
const articleRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLDivElement>(null)
const docsEntries = useMemo(() => getDocsEntries(lang, catalogItems), [catalogItems, lang])
const activeSlug = slug || defaultDocsSlug
const activeEntry = useMemo(() => getDocsEntry(activeSlug, docsEntries), [activeSlug, docsEntries])
const activeHeaderEntry = activeEntry || activeContentEntry
const groupedEntries = useMemo(() => groupDocsEntries(docsEntries), [docsEntries])
const effectiveTheme = themeMode === 'system' ? systemTheme : themeMode
const langOptions = useMemo(() => [
{ value: 'zh' as const, label: '中文' },
{ value: 'en' as const, label: 'EN' },
], [])
const themeOptions = useMemo(() => [
{
value: 'light' as const,
label: '浅色',
title: '浅色',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
),
},
{
value: 'system' as const,
label: '系统',
title: '跟随系统',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<path d="M8 21h8M12 17v4" />
</svg>
),
},
{
value: 'dark' as const,
label: '深色',
title: '深色',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
),
},
], [])
const handleLangChange = useCallback((newLang: DocsLang) => {
setLang(newLang)
localStorage.setItem('docs-lang', newLang)
}, [])
const handleThemeModeChange = useCallback((nextMode: DocsThemeMode) => {
setThemeMode(nextMode)
localStorage.setItem('docs-theme', nextMode)
}, [])
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => {
setSystemTheme(mediaQuery.matches ? 'dark' : 'light')
}
handleChange()
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
useEffect(() => {
let isCancelled = false
setIsCatalogLoading(true)
axios.get<DocsCatalogResponse>('/api/v1/docs/catalog')
.then((response) => {
if (!isCancelled) setCatalogItems(response.data.items || [])
})
.catch(() => {
if (!isCancelled) setCatalogItems([])
})
.finally(() => {
if (!isCancelled) setIsCatalogLoading(false)
})
return () => { isCancelled = true }
}, [token])
useEffect(() => {
let isCancelled = false
if (isCatalogLoading) return
setIsLoading(true)
setDocError('none')
setMarkdown('')
setActiveContentEntry(activeEntry || null)
axios.get<DocsContentResponse>(`/api/v1/docs/${lang}/${activeSlug}`)
.then((response) => {
if (isCancelled) return
const content = response.data
setMarkdown(content.markdown)
setActiveContentEntry({
slug: content.slug,
filename: content.filename,
title: content.title,
group: content.group,
order: content.order,
access: content.access,
})
})
.catch((error) => {
if (isCancelled) return
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 401) setDocError('unauthenticated')
else if (status === 403) setDocError('forbidden')
else if (status === 404) setDocError('not_found')
else setDocError('load_failed')
})
.finally(() => {
if (!isCancelled) setIsLoading(false)
})
return () => { isCancelled = true }
}, [activeEntry, activeSlug, isCatalogLoading, lang])
useEffect(() => {
if (!markdown || !window.location.hash) return
window.requestAnimationFrame(() => {
document.getElementById(decodeURIComponent(window.location.hash.slice(1)))?.scrollIntoView({ block: 'start' })
})
}, [markdown])
useEffect(() => {
let isCancelled = false
const loadMarkdown = async (entry: DocsEntry) => {
const response = await axios.get<DocsContentResponse>(`/api/v1/docs/${lang}/${entry.slug}`)
return response.data.markdown
}
buildDocsSearchRecords(docsEntries, loadMarkdown).then((records) => {
if (!isCancelled) setSearchRecords(records)
}).catch(() => {
if (!isCancelled) setSearchRecords([])
})
return () => { isCancelled = true }
}, [docsEntries, lang])
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!searchRef.current?.contains(event.target as Node)) {
setIsSearchOpen(false)
}
}
window.addEventListener('pointerdown', handlePointerDown)
return () => window.removeEventListener('pointerdown', handlePointerDown)
}, [])
const headings = useMemo<DocsHeading[]>(() => {
return markdown
? extractHeadings(markdown).filter((h) =>
h.level >= MIN_TOC_HEADING_LEVEL && h.level <= MAX_TOC_HEADING_LEVEL
)
: []
}, [markdown])
const makeHeadingIdResolver = useMemo(() => {
return markdown ? createHeadingIdResolver(markdown) : undefined
}, [markdown])
// Scroll spy: track which heading is currently at the top of the article
useEffect(() => {
const article = articleRef.current
if (!article || headings.length === 0) {
setActiveHeadingId('')
return
}
const handleScroll = () => {
const articleTop = article.getBoundingClientRect().top
let active = headings[0]?.id || ''
for (const { id } of headings) {
const el = document.getElementById(id)
if (el && el.getBoundingClientRect().top - articleTop < ACTIVE_HEADING_TOP_OFFSET_PX) {
active = id
}
}
setActiveHeadingId(active)
}
article.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => article.removeEventListener('scroll', handleScroll)
}, [headings])
const searchResults = useMemo(() => searchDocs(searchRecords, searchQuery), [searchQuery, searchRecords])
const shouldShowSearchResults = isSearchOpen && Boolean(searchQuery.trim())
const transformLink = useCallback((href: string) => {
const docsSlug = slugFromDocsHref(href)
if (docsSlug) return { href: `/docs/${docsSlug}${getHashFromHref(href)}`, external: false }
if (href.startsWith('#')) return { href, external: false }
return { href, external: true }
}, [])
const handleSearchSelect = useCallback((resultSlug: string) => {
setSearchQuery('')
setIsSearchOpen(false)
navigate(`/docs/${resultSlug}`)
}, [navigate])
const handleTocClick = useCallback((headingId: string) => {
const el = document.getElementById(headingId)
if (el && articleRef.current) {
const articleTop = articleRef.current.getBoundingClientRect().top
const elTop = el.getBoundingClientRect().top
articleRef.current.scrollBy({ top: elTop - articleTop - TOC_SCROLL_OFFSET_PX, behavior: 'smooth' })
}
}, [])
// Sidebar H2 sub-items for active Manual doc
const sidebarSubHeadings = useMemo(() => {
if (activeHeaderEntry?.group !== 'Manual' || headings.length === 0) return []
return headings.filter((h) => h.level === 2)
}, [activeHeaderEntry, headings])
return (
<main className="docs-page" data-theme={effectiveTheme}>
<aside className="docs-sidebar" aria-label="Documentation navigation">
<Link className="docs-brand" to="/docs">
<span className="docs-brand__mark"></span>
<span>
<span className="docs-brand__title">
{lang === 'zh' ? '智能星球文档' : 'Intelligent Planet Docs'}
</span>
<span className="docs-brand__subtitle">
{lang === 'zh' ? '开发者和用户手册' : 'Developer & User Guide'}
</span>
</span>
</Link>
<Scrollbar className="docs-nav">
{groupedEntries.map((group) => (
<section key={group.group} className="docs-nav__group">
<h2 className="docs-nav__heading">{getDocsGroupLabel(group.group, lang)}</h2>
{group.entries.map((entry) => {
const isActive = entry.slug === activeSlug
return (
<div key={entry.slug} className="docs-nav__item">
<Link
className={isActive ? 'docs-nav__link docs-nav__link--active' : 'docs-nav__link'}
to={`/docs/${entry.slug}`}
>
{entry.title}
</Link>
{isActive && sidebarSubHeadings.length > 0 && (
<div className="docs-nav__subitems">
{sidebarSubHeadings.map((h) => (
<button
key={h.id}
type="button"
className={h.id === activeHeadingId ? 'docs-nav__sublink docs-nav__sublink--active' : 'docs-nav__sublink'}
onClick={() => handleTocClick(h.id)}
>
{h.text}
</button>
))}
</div>
)}
</div>
)
})}
</section>
))}
</Scrollbar>
<footer className="docs-sidebar-footer">
<div className="docs-footer-row docs-footer-row--language">
<SegmentedControl
ariaLabel="Language"
className="docs-lang-toggle"
options={langOptions}
scale={FOOTER_CONTROL_SCALE}
value={lang}
onChange={handleLangChange}
/>
</div>
<div className="docs-footer-row">
<SegmentedControl
ariaLabel="Theme"
className="docs-theme-toggle"
options={themeOptions}
scale={FOOTER_CONTROL_SCALE}
value={themeMode}
onChange={handleThemeModeChange}
/>
</div>
</footer>
</aside>
<section className="docs-shell">
<header className="docs-header">
<div>
<p className="docs-header__eyebrow">
{activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : lang === 'zh' ? '文档' : 'Docs'}
</p>
<h1 className="docs-header__title">
{activeHeaderEntry?.title || (lang === 'zh' ? '文档不可用' : 'Document unavailable')}
</h1>
</div>
<div className="docs-search" ref={searchRef}>
<label className="docs-search__label" htmlFor="docs-search-input">
{lang === 'zh' ? '搜索文档' : 'Search docs'}
</label>
<input
id="docs-search-input"
className="docs-search__input"
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
setIsSearchOpen(Boolean(event.target.value.trim()))
}}
onFocus={() => {
if (searchQuery.trim()) {
setIsSearchOpen(true)
}
}}
placeholder={lang === 'zh' ? '搜索文档...' : 'Search guides, APIs, layers...'}
type="search"
/>
{shouldShowSearchResults && (
<div className="docs-search__results" role="listbox">
<Scrollbar className="docs-search__results-scroll">
{searchResults.length > 0 ? (
searchResults.map((result) => (
<button
key={result.entry.slug}
className="docs-search__result"
type="button"
onClick={() => handleSearchSelect(result.entry.slug)}
>
<span className="docs-search__result-title">{result.entry.title}</span>
<span className="docs-search__result-meta">
{getDocsGroupLabel(result.entry.group, lang)}
</span>
<span className="docs-search__result-excerpt">{result.excerpt}</span>
</button>
))
) : (
<div className="docs-search__empty">
{lang === 'zh' ? '未找到匹配文档' : 'No matching docs'}
</div>
)}
</Scrollbar>
</div>
)}
</div>
</header>
<div className="docs-content-layout">
<Scrollbar className="docs-article" viewportRef={articleRef}>
{isCatalogLoading || isLoading ? (
<div className="docs-state">
{lang === 'zh' ? '加载中...' : 'Loading document...'}
</div>
) : docError === 'none' ? (
<MarkdownRenderer
markdown={markdown}
className="docs-markdown"
getHeadingId={makeHeadingIdResolver}
transformLink={transformLink}
/>
) : (
<div className="docs-not-found">
{docError === 'unauthenticated' ? (
<>
<h2>{lang === 'zh' ? '需要登录' : 'Login required'}</h2>
<p>
{lang === 'zh'
? '这份文档需要登录并具备对应 Gatekeeper 权限组后才能阅读。'
: 'This document requires login and the matching Gatekeeper permission group.'}
</p>
<Link to="/admin">{lang === 'zh' ? '前往登录' : 'Go to login'}</Link>
</>
) : docError === 'forbidden' ? (
<>
<h2>{lang === 'zh' ? '无权访问' : 'Permission required'}</h2>
<p>
{lang === 'zh'
? '当前账号没有阅读这份文档所需的 Gatekeeper 权限组。'
: 'Your account does not have the Gatekeeper permission group required for this document.'}
</p>
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link>
</>
) : (
<>
<h2>{lang === 'zh' ? '文档未找到' : 'Document not found'}</h2>
<p>
{lang === 'zh'
? '请求的文档不存在,或当前语言没有对应内容。'
: 'The requested guide does not exist or is not available in the current language.'}
</p>
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link>
</>
)}
</div>
)}
</Scrollbar>
<aside className="docs-toc" aria-label="Document table of contents">
<Scrollbar className="docs-toc__inner">
<h2 className="docs-toc__title">
{lang === 'zh' ? '本页目录' : 'On this page'}
</h2>
{headings.length > 0 ? (
<nav className="docs-toc__nav">
{headings.map((heading) => (
<button
key={heading.id}
className={[
'docs-toc__link',
`docs-toc__link--level-${heading.level}`,
heading.id === activeHeadingId ? 'docs-toc__link--active' : '',
].filter(Boolean).join(' ')}
type="button"
onClick={() => handleTocClick(heading.id)}
>
{heading.text}
</button>
))}
</nav>
) : (
<p className="docs-toc__empty">
{lang === 'zh' ? '暂无章节' : 'No sections'}
</p>
)}
</Scrollbar>
</aside>
</div>
</section>
</main>
)
}