528 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|