mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
feat(api-docs): split the API docs page into tabs
The page stacked the WebSocket event cards above every Panel API operation
in one long scroll. The WebSocket events and the 3X-UI Panel API now sit in
separate tabs, and the Panel API shows one OpenAPI tag at a time through
section tabs placed between the Authorize bar and the operations.
The section tabs replace Swagger UI's FilterContainer and wrap the
taggedOperations selector, so all sections share one Swagger instance and
keep authorization and try-it-out state. Swagger's own filter matches tags
by substring ("Settings" would also show "Xray Settings") and does nothing
until set, so the wrapper matches the exact tag and defaults to the first.
Tag names come from the loaded spec rather than importing endpoints.ts,
which would have grown the page chunk from 23 kB to 119 kB.
This commit is contained in:
@@ -45,15 +45,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.api-docs-page .websocket-events {
|
.api-docs-page .websocket-events {
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--ant-color-border-secondary);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-docs-page .websocket-events h2 {
|
.api-docs-page .swagger-ui .section-tabs {
|
||||||
margin-top: 0;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-docs-page .websocket-events pre {
|
.api-docs-page .websocket-events pre {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd';
|
import { Card, Col, ConfigProvider, Layout, Row, Tabs, Typography } from 'antd';
|
||||||
import SwaggerUI from 'swagger-ui-react';
|
import SwaggerUI from 'swagger-ui-react';
|
||||||
import 'swagger-ui-react/swagger-ui.css';
|
import 'swagger-ui-react/swagger-ui.css';
|
||||||
|
|
||||||
@@ -14,6 +14,61 @@ const basePath = window.X_UI_BASE_PATH || '';
|
|||||||
const openApiUrl = `${basePath}panel/api/openapi.json`;
|
const openApiUrl = `${basePath}panel/api/openapi.json`;
|
||||||
const websocketEvents = buildWebSocketEvents(EXAMPLES);
|
const websocketEvents = buildWebSocketEvents(EXAMPLES);
|
||||||
|
|
||||||
|
interface TaggedOperations {
|
||||||
|
keySeq: () => { first: () => string | undefined };
|
||||||
|
filter: (keep: (operations: unknown, tag: string) => boolean) => TaggedOperations;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutSelectors {
|
||||||
|
currentFilter: () => string | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionTabsProps {
|
||||||
|
specSelectors: { tags: () => { toJS: () => { name: string }[] } };
|
||||||
|
layoutSelectors: LayoutSelectors;
|
||||||
|
layoutActions: { updateFilter: (tag: string) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTabs({ specSelectors, layoutSelectors, layoutActions }: SectionTabsProps) {
|
||||||
|
const tags = specSelectors
|
||||||
|
.tags()
|
||||||
|
.toJS()
|
||||||
|
.map((tag) => tag.name);
|
||||||
|
return (
|
||||||
|
<div className="wrapper section-tabs">
|
||||||
|
<Tabs
|
||||||
|
size="small"
|
||||||
|
activeKey={layoutSelectors.currentFilter() || tags[0]}
|
||||||
|
onChange={layoutActions.updateFilter}
|
||||||
|
items={tags.map((tag) => ({ key: tag, label: tag }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows one tag at a time, the first until a tab is picked. Swagger's own filter is a
|
||||||
|
// substring match ("Settings" would also show "Xray Settings") and no-op while unset.
|
||||||
|
const sectionTabsPlugin = {
|
||||||
|
statePlugins: {
|
||||||
|
spec: {
|
||||||
|
wrapSelectors: {
|
||||||
|
taggedOperations:
|
||||||
|
(
|
||||||
|
select: (...args: unknown[]) => TaggedOperations,
|
||||||
|
system: { getSystem: () => { layoutSelectors: LayoutSelectors } },
|
||||||
|
) =>
|
||||||
|
(...args: unknown[]) => {
|
||||||
|
const operations = select(...args);
|
||||||
|
const active =
|
||||||
|
system.getSystem().layoutSelectors.currentFilter() || operations.keySeq().first();
|
||||||
|
return operations.filter((_, tag) => tag === active);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: { FilterContainer: SectionTabs },
|
||||||
|
};
|
||||||
|
|
||||||
export default function ApiDocsPage() {
|
export default function ApiDocsPage() {
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,36 +87,54 @@ export default function ApiDocsPage() {
|
|||||||
|
|
||||||
<Layout className="content-shell">
|
<Layout className="content-shell">
|
||||||
<Layout.Content className="content-area">
|
<Layout.Content className="content-area">
|
||||||
<section className="websocket-events" aria-labelledby="websocket-events-title">
|
<Tabs
|
||||||
<Typography.Title id="websocket-events-title" level={2}>
|
items={[
|
||||||
WebSocket events
|
{
|
||||||
</Typography.Title>
|
key: 'panel-api',
|
||||||
<Typography.Paragraph>
|
label: '3X-UI Panel API',
|
||||||
After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '}
|
children: (
|
||||||
upgrade, every server message uses{' '}
|
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
||||||
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value
|
<SwaggerUI
|
||||||
is Unix milliseconds.
|
url={openApiUrl}
|
||||||
</Typography.Paragraph>
|
docExpansion="list"
|
||||||
<Row gutter={[12, 12]}>
|
deepLinking={false}
|
||||||
{websocketEvents.map((event) => (
|
plugins={[sectionTabsPlugin]}
|
||||||
<Col key={event.type} xs={24} sm={12} xl={8}>
|
tryItOutEnabled
|
||||||
<Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}>
|
persistAuthorization
|
||||||
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
/>
|
||||||
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
</div>
|
||||||
</Card>
|
),
|
||||||
</Col>
|
},
|
||||||
))}
|
{
|
||||||
</Row>
|
key: 'websocket-events',
|
||||||
</section>
|
label: 'WebSocket events',
|
||||||
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
children: (
|
||||||
<SwaggerUI
|
<section className="websocket-events">
|
||||||
url={openApiUrl}
|
<Typography.Paragraph>
|
||||||
docExpansion="list"
|
After the cookie-authenticated{' '}
|
||||||
deepLinking={false}
|
<Typography.Text code>GET /ws</Typography.Text> upgrade, every server
|
||||||
tryItOutEnabled
|
message uses{' '}
|
||||||
persistAuthorization
|
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The
|
||||||
/>
|
time value is Unix milliseconds.
|
||||||
</div>
|
</Typography.Paragraph>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
{websocketEvents.map((event) => (
|
||||||
|
<Col key={event.type} xs={24} sm={12} xl={8}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Typography.Text code>{event.type}</Typography.Text>}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
||||||
|
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
Reference in New Issue
Block a user