release: bump version to 0.49.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
linkong
2026-05-08 17:42:27 +08:00
parent bb9183b8a4
commit e1984c7a35
86 changed files with 9165 additions and 412 deletions

View File

@@ -1,10 +1,12 @@
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,
@@ -15,12 +17,23 @@ import {
groupDocsEntries,
slugFromDocsHref,
} from './docs-content'
import type { DocsHeading, DocsLang } 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
@@ -56,11 +69,16 @@ function getSystemTheme(): 'light' | 'dark' {
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)
@@ -69,9 +87,10 @@ export default function Docs() {
const articleRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLDivElement>(null)
const docsEntries = useMemo(() => getDocsEntries(lang), [lang])
const docsEntries = useMemo(() => getDocsEntries(lang, catalogItems), [catalogItems, lang])
const activeSlug = slug || defaultDocsSlug
const activeEntry = useMemo(() => getDocsEntry(activeSlug, lang), [activeSlug, lang])
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(() => [
@@ -140,20 +159,54 @@ export default function Docs() {
useEffect(() => {
let isCancelled = false
if (!activeEntry) {
setMarkdown('')
return
}
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)
activeEntry.loader()
.then((content) => {
if (!isCancelled) setMarkdown(content)
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])
}, [activeEntry, activeSlug, isCatalogLoading, lang])
useEffect(() => {
if (!markdown || !window.location.hash) return
@@ -164,11 +217,17 @@ export default function Docs() {
useEffect(() => {
let isCancelled = false
buildDocsSearchRecords(docsEntries).then((records) => {
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])
}, [docsEntries, lang])
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
@@ -243,9 +302,9 @@ export default function Docs() {
// Sidebar H2 sub-items for active Manual doc
const sidebarSubHeadings = useMemo(() => {
if (activeEntry?.group !== 'Manual' || headings.length === 0) return []
if (activeHeaderEntry?.group !== 'Manual' || headings.length === 0) return []
return headings.filter((h) => h.level === 2)
}, [activeEntry, headings])
}, [activeHeaderEntry, headings])
return (
<main className="docs-page" data-theme={effectiveTheme}>
@@ -326,9 +385,11 @@ export default function Docs() {
<header className="docs-header">
<div>
<p className="docs-header__eyebrow">
{activeEntry ? getDocsGroupLabel(activeEntry.group, lang) : 'Docs'}
{activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : 'Docs'}
</p>
<h1 className="docs-header__title">{activeEntry?.title || 'Document not found'}</h1>
<h1 className="docs-header__title">
{activeHeaderEntry?.title || (lang === 'zh' ? '文档不可用' : 'Document unavailable')}
</h1>
</div>
<div className="docs-search" ref={searchRef}>
@@ -382,30 +443,50 @@ export default function Docs() {
<div className="docs-content-layout">
<Scrollbar className="docs-article" viewportRef={articleRef}>
{activeEntry ? (
isLoading ? (
{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">
<h2>{lang === 'zh' ? '文档未找到' : 'Document not found'}</h2>
<p>
{lang === 'zh'
? '请求的文档不在公开文档集中。'
: 'The requested guide is not part of the public technical documentation set.'}
</p>
<Link to="/docs">
{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}
</Link>
{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>

View File

@@ -7,7 +7,19 @@ export interface DocsEntry {
title: string
group: DocsGroup
order: number
loader: () => Promise<string>
access: DocsAccess
}
export type DocsAccess = 'public' | 'docs_user' | 'docs_developer' | 'docs_admin'
export interface DocsCatalogItem {
slug: string
filename: string
lang: DocsLang
title: string
group: DocsGroup
order: number
access: DocsAccess
}
export interface DocsHeading {
@@ -16,7 +28,7 @@ export interface DocsHeading {
text: string
}
interface DocsMetadataEntry {
export interface DocsMetadataEntry {
zh: { title: string; group: DocsGroup; order: number }
en: { title: string; group: DocsGroup; order: number }
}
@@ -48,17 +60,7 @@ const DOCS_README_FILENAME = 'README.md'
const MAX_HEADING_ID_LENGTH = 80
export const defaultDocsSlug = 'overview'
const zhModules = import.meta.glob('../../../../docs/technical/zh/*.md', {
query: '?raw',
import: 'default',
})
const enModules = import.meta.glob('../../../../docs/technical/en/*.md', {
query: '?raw',
import: 'default',
})
const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
[DOCS_README_FILENAME]: {
zh: { title: '技术文档', group: 'Overview', order: 0 },
en: { title: 'Technical Docs', group: 'Overview', order: 0 },
@@ -71,6 +73,10 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: 'Planet 使用手册', group: 'Manual', order: 2 },
en: { title: 'Planet Manual', group: 'Manual', order: 2 },
},
'location-pipeline-user.md': {
zh: { title: 'Earth 位置候选采集使用手册', group: 'Manual', order: 3 },
en: { title: 'Earth Location Candidate Collection User Guide', group: 'Manual', order: 3 },
},
'earth-frontend-context.md': {
zh: { title: 'Earth 前端结构', group: 'Earth', order: 10 },
en: { title: 'Earth Frontend Context', group: 'Earth', order: 10 },
@@ -99,6 +105,10 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: 'Earth 可交互图标接入', group: 'Earth', order: 16 },
en: { title: 'Earth Interactable Usage', group: 'Earth', order: 16 },
},
'earth-toolbar-overlay-coordination.md': {
zh: { title: 'Earth 工具栏与浮层协同', group: 'Earth', order: 17 },
en: { title: 'Earth Toolbar and Overlay Coordination', group: 'Earth', order: 17 },
},
'frontend-admin-frontend-context.md': {
zh: { title: '控制台前端结构', group: 'Frontend', order: 20 },
en: { title: 'Admin Frontend Context', group: 'Frontend', order: 20 },
@@ -107,6 +117,10 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: '前端布局指南', group: 'Frontend', order: 21 },
en: { title: 'Frontend Layout Guidelines', group: 'Frontend', order: 21 },
},
'docs-gatekeeper-development.md': {
zh: { title: 'Docs Gatekeeper 开发说明', group: 'Frontend', order: 22 },
en: { title: 'Docs Gatekeeper Development Guide', group: 'Frontend', order: 22 },
},
'backend-collectors.md': {
zh: { title: '数据采集系统', group: 'Backend', order: 30 },
en: { title: 'Data Collectors', group: 'Backend', order: 30 },
@@ -119,6 +133,14 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: '数据源、采集器设置与连接验证', group: 'Backend', order: 32 },
en: { title: 'Datasource Collector Settings and Connectivity', group: 'Backend', order: 32 },
},
'backend-datasources-api-performance.md': {
zh: { title: '数据源 API 性能', group: 'Backend', order: 33 },
en: { title: 'Datasource API Performance', group: 'Backend', order: 33 },
},
'location-pipeline-development.md': {
zh: { title: '通用位置估算管线开发说明', group: 'Backend', order: 34 },
en: { title: 'Shared Location Resolution Pipeline Development Guide', group: 'Backend', order: 34 },
},
'agents-aiprovider.md': {
zh: { title: 'AI Provider 指南', group: 'Agents', order: 40 },
en: { title: 'AI Provider Guide', group: 'Agents', order: 40 },
@@ -127,47 +149,35 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: 'Docker + Compose + Buildx 升级', group: 'Ops', order: 50 },
en: { title: 'Docker + Compose + Buildx Upgrade', group: 'Ops', order: 50 },
},
'ops-planet-sh-startup.md': {
zh: { title: 'planet.sh 启动机制', group: 'Ops', order: 51 },
en: { title: 'planet.sh Startup', group: 'Ops', order: 51 },
},
}
const GROUP_ORDER: DocsGroup[] = ['Overview', 'Manual', 'Earth', 'Frontend', 'Backend', 'Agents', 'Ops', 'Other']
const ALL_KNOWN_SLUGS = new Set(
Object.keys(DOCS_METADATA).map((filename) =>
filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
)
)
function filenameFromPath(path: string): string {
return path.split('/').pop() || path
}
export function slugFromFilename(filename: string): string {
return filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
}
export function getDocsEntries(lang: DocsLang): DocsEntry[] {
const modules = lang === 'zh' ? zhModules : enModules
return Object.entries(modules)
.filter(([path]) => DOCS_METADATA[filenameFromPath(path)])
.map(([path, loader]) => {
const filename = filenameFromPath(path)
const meta = DOCS_METADATA[filename]
const langMeta = meta[lang]
return {
slug: slugFromFilename(filename),
filename,
title: langMeta.title,
group: langMeta.group,
order: langMeta.order,
loader: loader as () => Promise<string>,
}
})
export function getDocsEntries(lang: DocsLang, catalogItems: DocsCatalogItem[]): DocsEntry[] {
return catalogItems
.filter((item) => item.lang === lang)
.map((item) => ({
slug: item.slug,
filename: item.filename,
title: item.title,
group: item.group,
order: item.order,
access: item.access,
}))
.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title))
}
export function getDocsEntry(slug: string | undefined, lang: DocsLang): DocsEntry | undefined {
export function getDocsEntry(slug: string | undefined, entries: DocsEntry[]): DocsEntry | undefined {
const normalizedSlug = slug || defaultDocsSlug
return getDocsEntries(lang).find((entry) => entry.slug === normalizedSlug)
return entries.find((entry) => entry.slug === normalizedSlug)
}
export function groupDocsEntries(entries: DocsEntry[]): Array<{ group: DocsGroup; entries: DocsEntry[] }> {
@@ -236,6 +246,5 @@ export function slugFromDocsHref(href: string): string | null {
return null
}
const slug = slugFromFilename(filename)
return ALL_KNOWN_SLUGS.has(slug) ? slug : null
return slugFromFilename(filename)
}

View File

@@ -52,10 +52,13 @@ function createExcerpt(text: string, query: string): string {
return `${prefix}${text.slice(start, end)}${suffix}`
}
export async function buildDocsSearchRecords(entries: DocsEntry[]): Promise<DocsSearchRecord[]> {
export async function buildDocsSearchRecords(
entries: DocsEntry[],
loadMarkdown: (entry: DocsEntry) => Promise<string>,
): Promise<DocsSearchRecord[]> {
const records = await Promise.all(
entries.map(async (entry) => {
const markdown = await entry.loader()
const markdown = await loadMarkdown(entry)
return {
entry,
markdown,

View File

@@ -6,17 +6,20 @@ import { TableActions, actionCellProps } from '../../components/TableActions/Tab
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { useAuthStore } from '../../stores/auth'
interface User {
id: number
username: string
email: string
role: string
gatekeeper_groups: string[]
is_active: boolean
created_at: string
}
function Users() {
const { user: currentUser } = useAuthStore()
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
@@ -64,6 +67,9 @@ function Users() {
const handleSubmit = async (values: Record<string, unknown>) => {
try {
if (currentUser?.role !== 'super_admin') {
delete values.gatekeeper_groups
}
if (editingUser) {
await axios.put(`/api/v1/users/${editingUser.id}`, values)
message.success('更新成功')
@@ -98,6 +104,21 @@ function Users() {
return <Tag color={colors[role] || 'default'}>{role}</Tag>
},
},
{
title: 'Gatekeeper',
dataIndex: 'gatekeeper_groups',
key: 'gatekeeper_groups',
width: 260,
render: (groups: string[] = []) => (
<>
{groups.length > 0 ? groups.map((group) => (
<Tag key={group} color={group === 'docs_admin' ? 'red' : group === 'docs_developer' ? 'blue' : 'green'}>
{group}
</Tag>
)) : <Tag></Tag>}
</>
),
},
{
title: '状态',
dataIndex: 'is_active',
@@ -177,6 +198,18 @@ function Users() {
<Select.Option value="viewer"></Select.Option>
</Select>
</Form.Item>
<Form.Item name="gatekeeper_groups" label="Gatekeeper 权限组">
<Select
mode="multiple"
disabled={currentUser?.role !== 'super_admin'}
placeholder="选择 Docs 鉴权权限组"
options={[
{ value: 'docs_user', label: 'Docs 用户文档' },
{ value: 'docs_developer', label: 'Docs 开发文档' },
{ value: 'docs_admin', label: 'Docs 管理/运维文档' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block></Button>
</Form.Item>