release: bump version to 0.49.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user