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,20 +87,43 @@ 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',
|
||||||
|
label: '3X-UI Panel API',
|
||||||
|
children: (
|
||||||
|
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
||||||
|
<SwaggerUI
|
||||||
|
url={openApiUrl}
|
||||||
|
docExpansion="list"
|
||||||
|
deepLinking={false}
|
||||||
|
plugins={[sectionTabsPlugin]}
|
||||||
|
tryItOutEnabled
|
||||||
|
persistAuthorization
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'websocket-events',
|
||||||
|
label: 'WebSocket events',
|
||||||
|
children: (
|
||||||
|
<section className="websocket-events">
|
||||||
<Typography.Paragraph>
|
<Typography.Paragraph>
|
||||||
After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '}
|
After the cookie-authenticated{' '}
|
||||||
upgrade, every server message uses{' '}
|
<Typography.Text code>GET /ws</Typography.Text> upgrade, every server
|
||||||
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value
|
message uses{' '}
|
||||||
is Unix milliseconds.
|
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The
|
||||||
|
time value is Unix milliseconds.
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<Row gutter={[12, 12]}>
|
<Row gutter={[12, 12]}>
|
||||||
{websocketEvents.map((event) => (
|
{websocketEvents.map((event) => (
|
||||||
<Col key={event.type} xs={24} sm={12} xl={8}>
|
<Col key={event.type} xs={24} sm={12} xl={8}>
|
||||||
<Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}>
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Typography.Text code>{event.type}</Typography.Text>}
|
||||||
|
>
|
||||||
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
||||||
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -53,15 +131,10 @@ export default function ApiDocsPage() {
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
</section>
|
</section>
|
||||||
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
),
|
||||||
<SwaggerUI
|
},
|
||||||
url={openApiUrl}
|
]}
|
||||||
docExpansion="list"
|
|
||||||
deepLinking={false}
|
|
||||||
tryItOutEnabled
|
|
||||||
persistAuthorization
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
Reference in New Issue
Block a user