1 Commits
Author SHA1 Message Date
Muse 5f637a9b92 精简版前端: mixed 放开节点部署(NODE_ELIGIBLE_PROTOCOLS); 去掉 API 文档页(Swagger UI+openapi 生成); 去掉分组管理页与加组/解组入口
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
2026-10-20 10:24:25 +00:00
19 changed files with 6 additions and 17374 deletions
+1 -2
View File
@@ -10,7 +10,7 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "npm run gen:api && vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "oxlint src tools", "lint": "oxlint src tools",
"lint:fix": "oxlint --fix src tools", "lint:fix": "oxlint --fix src tools",
@@ -23,7 +23,6 @@
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build", "build-storybook": "storybook build",
"gen": "npm run gen:zod && npm run gen:api", "gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen", "gen:zod": "cd .. && go run ./tools/openapigen",
"prepare": "cd .. && husky frontend/.husky || true" "prepare": "cd .. && husky frontend/.husky || true"
}, },
File diff suppressed because it is too large Load Diff
-246
View File
@@ -1,246 +0,0 @@
#!/usr/bin/env node
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sections } from '../src/pages/api-docs/endpoints.ts';
import { EXAMPLES } from '../src/generated/examples.ts';
import { SCHEMAS } from '../src/generated/schemas.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outPath = join(__dirname, '..', 'public', 'openapi.json');
const PANEL_VERSION = process.env.X_UI_VERSION || '3.x';
const SECURITY_SCHEMES = {
bearerAuth: {
type: 'http',
scheme: 'bearer',
description: 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
},
cookieAuth: {
type: 'apiKey',
in: 'cookie',
name: '3x-ui',
description: 'Session cookie set by POST /login. Browser-only.',
},
};
function ginPathToOpenApi(path) {
return path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
}
function extractPathParams(openApiPath) {
const params = [];
const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
let m;
while ((m = re.exec(openApiPath)) !== null) params.push(m[1]);
return params;
}
function mapType(t) {
const v = String(t || '').toLowerCase();
if (v.endsWith('[]')) return 'array';
if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
if (v === 'float' || v === 'double') return 'number';
if (v === 'boolean' || v === 'bool') return 'boolean';
if (v === 'array') return 'array';
if (v === 'object') return 'object';
return 'string';
}
function schemaFromType(t) {
const v = String(t || '').toLowerCase();
if (v.endsWith('[]')) {
const itemType = v.slice(0, -2);
return { type: 'array', items: { type: mapType(itemType) } };
}
return { type: mapType(v) };
}
function tryParseJson(raw) {
if (typeof raw !== 'string') return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
function paramToOpenApi(p) {
const out = {
name: p.name,
in: p.in,
required: p.in === 'path' ? true : !p.optional,
description: p.desc || '',
schema: schemaFromType(p.type),
};
if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
return out;
}
function buildOperation(ep, tag) {
const op = {
tags: [tag],
summary: ep.summary || '',
operationId: `${ep.method.toLowerCase()}_${ep.path.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
};
if (ep.description) op.description = ep.description;
if (ep.deprecated) op.deprecated = true;
const params = [];
const bodyParams = [];
for (const p of ep.params || []) {
if (p.in === 'body') {
bodyParams.push(p);
} else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
params.push(paramToOpenApi(p));
}
}
const openApiPath = ginPathToOpenApi(ep.path);
const declared = new Set(params.filter((x) => x.in === 'path').map((x) => x.name));
for (const name of extractPathParams(openApiPath)) {
if (declared.has(name)) continue;
params.push({
name,
in: 'path',
required: true,
description: '',
schema: { type: 'string' },
});
}
if (params.length > 0) op.parameters = params;
if (ep.body || bodyParams.length > 0) {
const example = tryParseJson(ep.body);
const properties = {};
const required = [];
for (const bp of bodyParams) {
properties[bp.name] = {
...schemaFromType(bp.type),
description: bp.desc || '',
};
if (!bp.optional) required.push(bp.name);
}
const schema = bodyParams.length > 0
? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
: { type: 'object' };
op.requestBody = {
required: required.length > 0 || bodyParams.length === 0,
content: {
'application/json': {
schema,
...(example !== undefined ? { example } : {}),
},
},
};
}
const responses = {};
let successExample = tryParseJson(ep.response);
let objSchema = {};
if (ep.responseSchema) {
const obj = EXAMPLES[ep.responseSchema];
if (obj === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
}
if (SCHEMAS[ep.responseSchema] === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
}
const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
if (successExample === undefined) {
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
}
}
responses['200'] = {
description: 'Successful response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
obj: objSchema,
},
},
...(successExample !== undefined ? { example: successExample } : {}),
},
},
};
const errExample = tryParseJson(ep.errorResponse);
if (errExample !== undefined || ep.errorStatus) {
const code = String(ep.errorStatus || 400);
responses[code] = {
description: 'Error response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
},
},
...(errExample !== undefined ? { example: errExample } : {}),
},
},
};
}
op.responses = responses;
return op;
}
function buildSpec() {
const paths = {};
for (const section of sections) {
const tag = section.title;
for (const ep of section.endpoints) {
const openApiPath = ginPathToOpenApi(ep.path);
if (!paths[openApiPath]) paths[openApiPath] = {};
paths[openApiPath][ep.method.toLowerCase()] = buildOperation(ep, tag);
}
}
const tags = sections.map((s) => ({
name: s.title,
description: s.description || '',
}));
return {
openapi: '3.0.3',
info: {
title: '3X-UI Panel API',
version: PANEL_VERSION,
description:
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
},
servers: [
{ url: '/', description: 'Current panel (basePath aware)' },
],
components: {
securitySchemes: SECURITY_SCHEMES,
schemas: SCHEMAS,
},
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
tags,
paths,
};
}
const spec = buildSpec();
writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
const pathCount = Object.keys(spec.paths).length;
let opCount = 0;
for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
console.log(`[openapi] wrote ${outPath}`);
console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
void pathToFileURL;
-32
View File
@@ -328,22 +328,6 @@ export function useClients(options: UseClientsOptions = {}) {
}, },
}); });
const bulkAddToGroupMut = useMutation({
mutationFn: (body: { emails: string[]; group: string }) =>
HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidateAll();
},
});
const bulkRemoveFromGroupMut = useMutation({
mutationFn: (body: { emails: string[] }) =>
HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidateAll();
},
});
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ email, client }: { email: string; client: unknown }) => mutationFn: ({ email, client }: { email: string; client: unknown }) =>
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS), HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
@@ -583,20 +567,6 @@ export function useClients(options: UseClientsOptions = {}) {
}, },
[bulkSetEnableMut], [bulkSetEnableMut],
); );
const bulkAddToGroup = useCallback(
(emails: string[], group: string) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAddToGroupMut.mutateAsync({ emails, group });
},
[bulkAddToGroupMut],
);
const bulkRemoveFromGroup = useCallback(
(emails: string[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkRemoveFromGroupMut.mutateAsync({ emails });
},
[bulkRemoveFromGroupMut],
);
const attach = useCallback( const attach = useCallback(
(email: string, inboundIds: number[]) => { (email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>); if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
@@ -807,8 +777,6 @@ export function useClients(options: UseClientsOptions = {}) {
bulkAdjust, bulkAdjust,
bulkEnable, bulkEnable,
bulkDisable, bulkDisable,
bulkAddToGroup,
bulkRemoveFromGroup,
attach, attach,
setExternalLinks, setExternalLinks,
bulkAttach, bulkAttach,
-2
View File
@@ -6,14 +6,12 @@ const TITLE_KEYS: Record<string, string> = {
'/': 'menu.dashboard', '/': 'menu.dashboard',
'/inbounds': 'menu.inbounds', '/inbounds': 'menu.inbounds',
'/clients': 'menu.clients', '/clients': 'menu.clients',
'/groups': 'menu.groups',
'/nodes': 'menu.nodes', '/nodes': 'menu.nodes',
'/hosts': 'menu.hosts', '/hosts': 'menu.hosts',
'/settings': 'menu.settings', '/settings': 'menu.settings',
'/xray': 'menu.xray', '/xray': 'menu.xray',
'/outbound': 'menu.outbounds', '/outbound': 'menu.outbounds',
'/routing': 'menu.routing', '/routing': 'menu.routing',
'/api-docs': 'menu.apiDocs',
}; };
export function usePageTitle() { export function usePageTitle() {
-2
View File
@@ -217,14 +217,12 @@ export default function AppSidebar() {
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') }, { key: '/', icon: 'dashboard', title: t('menu.dashboard') },
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') }, { key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
{ key: '/clients', icon: 'team', title: t('menu.clients') }, { key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') }, { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') }, { key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') }, { key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
{ key: '/routing', icon: 'routing', title: t('menu.routing') }, { key: '/routing', icon: 'routing', title: t('menu.routing') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') }, { key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') }, { key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') }, { key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
], ],
[t], [t],
+5 -1
View File
@@ -2,7 +2,10 @@ import { Protocols } from '@/schemas/primitives';
/* /*
* Protocols whose inbounds can live on a sub-node (the "Deploy To" set). * Protocols whose inbounds can live on a sub-node (the "Deploy To" set).
* Everything else (http, mixed, tunnel, tun, mtproto) is panel-local only. * Everything else (http, tunnel, tun, mtproto) is panel-local only. Mixed
* is eligible: it is a plain xray inbound, and node-local mixed inbounds
* already sync back to the panel — hiding the picker only blocked creating
* them from the master.
* Shared by the inbound form's Deploy To selector and the clone dialog's * Shared by the inbound form's Deploy To selector and the clone dialog's
* target picker so the two surfaces can never drift apart. * target picker so the two surfaces can never drift apart.
*/ */
@@ -13,4 +16,5 @@ export const NODE_ELIGIBLE_PROTOCOLS: Readonly<Record<string, true>> = {
[Protocols.SHADOWSOCKS]: true, [Protocols.SHADOWSOCKS]: true,
[Protocols.HYSTERIA]: true, [Protocols.HYSTERIA]: true,
[Protocols.WIREGUARD]: true, [Protocols.WIREGUARD]: true,
[Protocols.MIXED]: true,
}; };
-421
View File
@@ -1,421 +0,0 @@
.api-docs-page.is-dark {
--sw-bg: #1f2026;
--sw-bg-soft: #25272e;
--sw-bg-input: #15161a;
--sw-bg-code: #0d0e12;
--sw-border: rgba(255, 255, 255, 0.08);
--sw-border-strong: rgba(255, 255, 255, 0.15);
--sw-text: rgba(255, 255, 255, 0.88);
--sw-text-muted: rgba(255, 255, 255, 0.6);
--sw-text-dim: rgba(255, 255, 255, 0.45);
--sw-accent: #58a6ff;
color-scheme: dark;
}
.api-docs-page.is-dark.is-ultra {
--sw-bg: #0a0a0d;
--sw-bg-soft: #131316;
--sw-bg-input: #050507;
--sw-bg-code: #000;
--sw-border: rgba(255, 255, 255, 0.06);
--sw-border-strong: rgba(255, 255, 255, 0.12);
}
.api-docs-page .content-shell {
background: var(--bg-page);
}
.api-docs-page .content-area {
padding: 16px;
max-width: 100%;
}
@media (max-width: 768px) {
.api-docs-page .content-area {
padding: 8px;
padding-top: 56px;
}
}
.api-docs-page .docs-wrapper {
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--ant-color-border-secondary);
overflow: hidden;
}
.api-docs-page .swagger-ui {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* ──────────────────────────────────────────────────────────────────
Dark mode — Swagger UI does not ship a dark theme, so every visual
surface needs an explicit override. Method-color chips (GET / POST /
…) are left untouched because they carry meaning at a glance.
────────────────────────────────────────────────────────────────── */
.api-docs-page.is-dark .swagger-ui,
.api-docs-page.is-dark .swagger-ui .info .title,
.api-docs-page.is-dark .swagger-ui .info .title small pre,
.api-docs-page.is-dark .swagger-ui .info p,
.api-docs-page.is-dark .swagger-ui .info li,
.api-docs-page.is-dark .swagger-ui .info table,
.api-docs-page.is-dark .swagger-ui .opblock-tag,
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path__deprecated,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-description,
.api-docs-page.is-dark .swagger-ui .opblock-description-wrapper p,
.api-docs-page.is-dark .swagger-ui .opblock-external-docs-wrapper p,
.api-docs-page.is-dark .swagger-ui .opblock-title_normal p,
.api-docs-page.is-dark .swagger-ui table thead tr td,
.api-docs-page.is-dark .swagger-ui table thead tr th,
.api-docs-page.is-dark .swagger-ui table tbody tr td,
.api-docs-page.is-dark .swagger-ui .parameter__name,
.api-docs-page.is-dark .swagger-ui .parameter__type,
.api-docs-page.is-dark .swagger-ui .parameter__in,
.api-docs-page.is-dark .swagger-ui .parameter__extension,
.api-docs-page.is-dark .swagger-ui .response-col_status,
.api-docs-page.is-dark .swagger-ui .response-col_description,
.api-docs-page.is-dark .swagger-ui .response-col_links,
.api-docs-page.is-dark .swagger-ui .responses-inner h4,
.api-docs-page.is-dark .swagger-ui .responses-inner h5,
.api-docs-page.is-dark .swagger-ui label,
.api-docs-page.is-dark .swagger-ui .tab li,
.api-docs-page.is-dark .swagger-ui .tab li button,
.api-docs-page.is-dark .swagger-ui .markdown,
.api-docs-page.is-dark .swagger-ui .markdown p,
.api-docs-page.is-dark .swagger-ui .markdown li,
.api-docs-page.is-dark .swagger-ui .renderedmarkdown p,
.api-docs-page.is-dark .swagger-ui .renderedmarkdown li,
.api-docs-page.is-dark .swagger-ui .model-title,
.api-docs-page.is-dark .swagger-ui .model,
.api-docs-page.is-dark .swagger-ui .model-toggle:after,
.api-docs-page.is-dark .swagger-ui section.models h4,
.api-docs-page.is-dark .swagger-ui section.models h5,
.api-docs-page.is-dark .swagger-ui .auth-container h4,
.api-docs-page.is-dark .swagger-ui .auth-container h6,
.api-docs-page.is-dark .swagger-ui .scopes h2,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header h3,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content h4,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content p,
.api-docs-page.is-dark .swagger-ui .servers-title {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
.api-docs-page.is-dark .swagger-ui .opblock-summary-description,
.api-docs-page.is-dark .swagger-ui .parameter__in,
.api-docs-page.is-dark .swagger-ui .parameter__type,
.api-docs-page.is-dark .swagger-ui .parameter__extension,
.api-docs-page.is-dark .swagger-ui .opblock-title_normal small,
.api-docs-page.is-dark .swagger-ui .servers > label,
.api-docs-page.is-dark .swagger-ui .servers > label span,
.api-docs-page.is-dark .swagger-ui .response-control-media-type__accept-message {
color: var(--sw-text-muted);
}
.api-docs-page.is-dark .swagger-ui .opblock-tag {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .opblock {
background: var(--sw-bg-soft);
border-color: var(--sw-border);
box-shadow: none;
}
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header {
background: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 -1px 0 var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header h4,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header label {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .opblock-body pre.microlight,
.api-docs-page.is-dark .swagger-ui .highlight-code,
.api-docs-page.is-dark .swagger-ui .microlight,
.api-docs-page.is-dark .swagger-ui pre.example,
.api-docs-page.is-dark .swagger-ui code {
background: var(--sw-bg-code);
color: rgba(255, 255, 255, 0.92);
}
.api-docs-page.is-dark .swagger-ui .highlight-code .copy-to-clipboard {
background: rgba(255, 255, 255, 0.06);
}
.api-docs-page.is-dark .swagger-ui input[type='text'],
.api-docs-page.is-dark .swagger-ui input[type='password'],
.api-docs-page.is-dark .swagger-ui input[type='search'],
.api-docs-page.is-dark .swagger-ui input[type='email'],
.api-docs-page.is-dark .swagger-ui input[type='file'],
.api-docs-page.is-dark .swagger-ui textarea {
background: var(--sw-bg-input);
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui select {
background-color: var(--sw-bg-input);
background-image: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='%23b7bcbf' d='M13.418 7.859a.695.695 0 0 1 .978 0 .68.68 0 0 1 0 .969l-3.908 3.83a.697.697 0 0 1-.979 0l-3.908-3.83a.68.68 0 0 1 0-.969.695.695 0 0 1 .978 0L10 11z'/></svg>");
background-position: right 10px center;
background-repeat: no-repeat;
background-size: 20px;
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui select option {
background-color: var(--sw-bg-input);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.api-docs-page.is-dark .swagger-ui input::placeholder,
.api-docs-page.is-dark .swagger-ui textarea::placeholder {
color: var(--sw-text-dim);
}
.api-docs-page.is-dark .swagger-ui .scheme-container {
background: var(--sw-bg-soft);
box-shadow: inset 0 -1px 0 var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize {
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize svg {
fill: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .model-box,
.api-docs-page.is-dark .swagger-ui section.models {
background: var(--sw-bg-soft);
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models.is-open h4 {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models .model-container {
background: rgba(255, 255, 255, 0.02);
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models .model-container:hover {
background: rgba(255, 255, 255, 0.04);
}
.api-docs-page.is-dark .swagger-ui .prop-type,
.api-docs-page.is-dark .swagger-ui .prop-format {
color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui .property.primitive {
color: var(--sw-text-muted);
}
.api-docs-page.is-dark .swagger-ui .opblock-title_normal h4 {
color: var(--sw-text);
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui table.parameters,
.api-docs-page.is-dark .swagger-ui table.responses-table,
.api-docs-page.is-dark .swagger-ui table thead tr {
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui table tbody tr td {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui table thead tr {
border-bottom-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .response-col_status {
font-weight: 600;
}
.api-docs-page.is-dark .swagger-ui .btn {
background: rgba(255, 255, 255, 0.06);
color: var(--sw-text);
border-color: var(--sw-border-strong);
box-shadow: none;
}
.api-docs-page.is-dark .swagger-ui .btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.api-docs-page.is-dark .swagger-ui .btn.execute {
background: var(--sw-accent);
color: #0d1117;
border-color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui .btn.execute:hover {
background: #79b9ff;
}
.api-docs-page.is-dark .swagger-ui .btn.authorize {
color: #52c41a;
border-color: rgba(82, 196, 26, 0.4);
background: rgba(82, 196, 26, 0.08);
}
.api-docs-page.is-dark .swagger-ui .btn.authorize svg {
fill: #52c41a;
}
.api-docs-page.is-dark .swagger-ui .authorization__btn svg,
.api-docs-page.is-dark .swagger-ui .expand-operation svg,
.api-docs-page.is-dark .swagger-ui .opblock-control-arrow svg {
fill: var(--sw-text);
opacity: 1;
}
.api-docs-page.is-dark .swagger-ui .authorization__btn .locked,
.api-docs-page.is-dark .swagger-ui .authorization__btn .unlocked {
opacity: 1;
}
.api-docs-page.is-dark .swagger-ui .btn.cancel {
color: #ff7875;
border-color: rgba(255, 120, 117, 0.4);
background: rgba(255, 120, 117, 0.08);
}
.api-docs-page.is-dark .swagger-ui .btn.btn-clear,
.api-docs-page.is-dark .swagger-ui .btn-clear,
.api-docs-page.is-dark .swagger-ui .try-out__btn {
color: var(--sw-text);
background: rgba(255, 255, 255, 0.06);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .btn.btn-clear:hover,
.api-docs-page.is-dark .swagger-ui .btn-clear:hover,
.api-docs-page.is-dark .swagger-ui .try-out__btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.api-docs-page.is-dark .swagger-ui .filter .operation-filter-input {
background: var(--sw-bg-input);
border-color: var(--sw-border-strong);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux {
background: var(--sw-bg);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .arrow,
.api-docs-page.is-dark .swagger-ui svg.arrow {
fill: var(--sw-text-muted);
}
.api-docs-page.is-dark .swagger-ui .opblock-summary-control:focus {
outline-color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui a,
.api-docs-page.is-dark .swagger-ui .info a,
.api-docs-page.is-dark .swagger-ui .info hgroup.main a,
.api-docs-page.is-dark .swagger-ui .info .base-url,
.api-docs-page.is-dark .swagger-ui .info__contact a,
.api-docs-page.is-dark .swagger-ui .info__license a,
.api-docs-page.is-dark .swagger-ui .info__tos a {
color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui a:hover {
color: #79b9ff;
}
.api-docs-page.is-dark .swagger-ui .info .title small {
background: rgba(255, 255, 255, 0.08);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .info .title small pre {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .response-control-media-type--accept-controller select {
border-color: rgba(82, 196, 26, 0.5);
}
.api-docs-page.is-dark .swagger-ui .loading-container .loading:before {
border-color: var(--sw-accent) transparent transparent;
}
.api-docs-page.is-dark .swagger-ui .json-schema-form-item input,
.api-docs-page.is-dark .swagger-ui .json-schema-form-item select {
background: var(--sw-bg-input);
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .topbar {
background: var(--sw-bg);
}
.api-docs-page.is-dark .swagger-ui .information-container {
background: transparent;
}
.api-docs-page.is-dark .swagger-ui .opblock-summary-method {
text-shadow: none;
}
.api-docs-page.is-dark .swagger-ui .auth-btn-wrapper {
border-top-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .servers .computed-url,
.api-docs-page.is-dark .swagger-ui .computed-url {
background: var(--sw-bg-code);
color: var(--sw-text);
border: 1px solid var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .computed-url code,
.api-docs-page.is-dark .swagger-ui .servers .computed-url code {
background: transparent;
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .errors-wrapper {
background: rgba(255, 77, 79, 0.08);
border-color: rgba(255, 77, 79, 0.3);
}
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors h4,
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors small {
color: var(--sw-text);
}
@@ -1,46 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ConfigProvider, Layout } from 'antd';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import './ApiDocsPage.css';
const basePath = window.X_UI_BASE_PATH || '';
const openApiUrl = `${basePath}panel/api/openapi.json`;
export default function ApiDocsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { t } = useTranslation();
const pageClass = useMemo(() => {
const classes = ['api-docs-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
return (
<ConfigProvider theme={antdThemeConfig}>
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content className="content-area">
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
<SwaggerUI
url={openApiUrl}
docExpansion="list"
deepLinking={false}
tryItOutEnabled
persistAuthorization
/>
</div>
</Layout.Content>
</Layout>
</Layout>
</ConfigProvider>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,89 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AutoComplete, Form, Modal, message } from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
type GroupFormValues = { group: string };
const EMPTY: GroupFormValues = { group: '' };
interface BulkAddToGroupModalProps {
open: boolean;
count: number;
groups: string[];
onOpenChange: (open: boolean) => void;
onSubmit: (group: string) => Promise<{ affected?: number } | null>;
}
export default function BulkAddToGroupModal({
open,
count,
groups,
onOpenChange,
onSubmit,
}: BulkAddToGroupModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [submitting, setSubmitting] = useState(false);
const methods = useForm<GroupFormValues>({ defaultValues: EMPTY });
const group = useWatch({ control: methods.control, name: 'group' });
useEffect(() => {
if (open) methods.reset(EMPTY);
}, [open, methods]);
async function submit() {
const next = (methods.getValues().group ?? '').trim();
if (!next) return;
setSubmitting(true);
try {
const result = await onSubmit(next);
if (result) {
const affected = result.affected ?? 0;
messageApi.success(
t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }),
);
onOpenChange(false);
}
} finally {
setSubmitting(false);
}
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={t('pages.clients.addToGroupTitle', { count })}
okText={t('add')}
cancelText={t('cancel')}
confirmLoading={submitting}
okButtonProps={{ disabled: !(group ?? '').trim() }}
onCancel={() => onOpenChange(false)}
onOk={submit}
destroyOnHidden
>
<FormProvider {...methods}>
<Form layout="vertical">
<FormField
name="group"
label={t('pages.clients.group')}
tooltip={t('pages.clients.addToGroupTooltip')}
transform={{ output: (v) => v ?? '' }}
>
<AutoComplete
placeholder={t('pages.clients.groupName')}
options={groups.map((g) => ({ value: g }))}
allowClear
autoFocus
/>
</FormField>
</Form>
</FormProvider>
</Modal>
</>
);
}
@@ -83,7 +83,6 @@ const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal')); const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
const FilterDrawer = lazy(() => import('./FilterDrawer')); const FilterDrawer = lazy(() => import('./FilterDrawer'));
const SubLinksModal = lazy(() => import('./SubLinksModal')); const SubLinksModal = lazy(() => import('./SubLinksModal'));
const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal')); const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal')); const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
const TextModal = lazy(() => import('@/components/feedback/TextModal')); const TextModal = lazy(() => import('@/components/feedback/TextModal'));
@@ -314,8 +313,6 @@ export default function ClientsPage() {
bulkAdjust, bulkAdjust,
bulkEnable, bulkEnable,
bulkDisable, bulkDisable,
bulkAddToGroup,
bulkRemoveFromGroup,
attach, attach,
setExternalLinks, setExternalLinks,
bulkAttach, bulkAttach,
@@ -363,7 +360,6 @@ export default function ClientsPage() {
const [bulkAddOpen, setBulkAddOpen] = useState(false); const [bulkAddOpen, setBulkAddOpen] = useState(false);
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false); const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
const [subLinksOpen, setSubLinksOpen] = useState(false); const [subLinksOpen, setSubLinksOpen] = useState(false);
const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
const [bulkAttachOpen, setBulkAttachOpen] = useState(false); const [bulkAttachOpen, setBulkAttachOpen] = useState(false);
const [bulkDetachOpen, setBulkDetachOpen] = useState(false); const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
@@ -846,27 +842,6 @@ export default function ClientsPage() {
}); });
} }
function onBulkUngroup() {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
modal.confirm({
title: t('pages.clients.ungroupConfirmTitle', { count: emails.length }),
content: t('pages.clients.ungroupConfirmContent'),
okText: t('confirm'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await bulkRemoveFromGroup(emails);
if (msg?.success) {
setSelectedRowKeys([]);
const affected =
(msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
messageApi.success(t('pages.clients.ungroupSuccessToast', { count: affected }));
}
},
});
}
function onBulkSetEnable(enable: boolean) { function onBulkSetEnable(enable: boolean) {
const emails = [...selectedRowKeys]; const emails = [...selectedRowKeys];
if (emails.length === 0) return; if (emails.length === 0) return;
@@ -1382,19 +1357,6 @@ export default function ClientsPage() {
danger: true, danger: true,
onClick: () => setBulkDetachOpen(true), onClick: () => setBulkDetachOpen(true),
}, },
{
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.clients.addToGroup'),
onClick: () => setBulkGroupOpen(true),
},
{
key: 'ungroup',
icon: <UngroupIcon />,
label: t('pages.clients.ungroup'),
danger: true,
onClick: onBulkUngroup,
},
{ type: 'divider' as const }, { type: 'divider' as const },
{ {
key: 'enable', key: 'enable',
@@ -1912,22 +1874,6 @@ export default function ClientsPage() {
onOpenChange={setSubLinksOpen} onOpenChange={setSubLinksOpen}
/> />
</LazyMount> </LazyMount>
<LazyMount when={bulkGroupOpen}>
<BulkAddToGroupModal
open={bulkGroupOpen}
count={selectedRowKeys.length}
groups={allGroups}
onOpenChange={setBulkGroupOpen}
onSubmit={async (group) => {
const msg = await bulkAddToGroup([...selectedRowKeys], group);
if (msg?.success) {
setSelectedRowKeys([]);
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={bulkAttachOpen}> <LazyMount when={bulkAttachOpen}>
<BulkAttachInboundsModal <BulkAttachInboundsModal
open={bulkAttachOpen} open={bulkAttachOpen}
@@ -1,170 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ClientRecord } from '@/hooks/useClients';
interface GroupAddClientsModalProps {
open: boolean;
groupName: string | null;
candidates: ClientRecord[];
onClose: () => void;
onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
}
interface ClientRow {
email: string;
comment: string;
enable: boolean;
currentGroup: string;
}
export default function GroupAddClientsModal({
open,
groupName,
candidates,
onClose,
onSubmit,
}: GroupAddClientsModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [saving, setSaving] = useState(false);
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
const [search, setSearch] = useState('');
const rows = useMemo<ClientRow[]>(
() =>
(candidates || [])
.map((c) => ({
email: (c.email || '').trim(),
comment: (c.comment || '').trim(),
enable: c.enable !== false,
currentGroup: (c.group || '').trim(),
}))
.filter((r) => r.email),
[candidates],
);
// React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setSelectedEmails([]);
setSearch('');
}
}
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) =>
r.email.toLowerCase().includes(q) ||
r.comment.toLowerCase().includes(q) ||
r.currentGroup.toLowerCase().includes(q),
);
}, [rows, search]);
const columns: ColumnsType<ClientRow> = useMemo(
() => [
{ title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
{ title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
{
title: t('pages.clients.group'),
dataIndex: 'currentGroup',
key: 'currentGroup',
width: 140,
ellipsis: true,
render: (g: string) =>
g ? (
<Tag color="geekblue">{g}</Tag>
) : (
<span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>
),
},
{
title: t('enable'),
dataIndex: 'enable',
key: 'enable',
width: 90,
render: (enabled: boolean) =>
enabled ? (
<Tag color="success">{t('enable')}</Tag>
) : (
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
),
},
],
[t],
);
async function submit() {
if (!groupName || selectedEmails.length === 0) return;
setSaving(true);
try {
const result = await onSubmit(selectedEmails);
if (!result) return;
const affected = result.affected ?? selectedEmails.length;
messageApi.success(t('pages.groups.addToGroupResult', { count: affected, name: groupName }));
onClose();
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onCancel={onClose}
onOk={submit}
okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
okText={t('add')}
cancelText={t('cancel')}
title={t('pages.groups.addToGroupTitle', { name: groupName ?? '' })}
width={720}
>
{messageContextHolder}
<Typography.Paragraph type="secondary">
{t('pages.groups.addToGroupDesc')}
</Typography.Paragraph>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search
allowClear
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
style={{ maxWidth: 320 }}
/>
<Typography.Text type="secondary">
{t('pages.inbounds.attachClientsSelectedCount', {
selected: selectedEmails.length,
total: rows.length,
})}
</Typography.Text>
</Space>
{rows.length === 0 ? (
<Alert type="info" showIcon title={t('pages.groups.addToGroupEmpty')} />
) : (
<Table<ClientRow>
size="small"
rowKey="email"
columns={columns}
dataSource={filteredRows}
pagination={false}
scroll={{ y: 320 }}
rowSelection={{
selectedRowKeys: selectedEmails,
onChange: (keys) => setSelectedEmails(keys as string[]),
preserveSelectedRowKeys: true,
}}
/>
)}
</Space>
</Modal>
);
}
@@ -1,150 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ClientRecord } from '@/hooks/useClients';
interface GroupRemoveClientsModalProps {
open: boolean;
groupName: string | null;
members: ClientRecord[];
onClose: () => void;
onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
}
interface ClientRow {
email: string;
comment: string;
enable: boolean;
}
export default function GroupRemoveClientsModal({
open,
groupName,
members,
onClose,
onSubmit,
}: GroupRemoveClientsModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [saving, setSaving] = useState(false);
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
const [search, setSearch] = useState('');
const rows = useMemo<ClientRow[]>(
() =>
(members || [])
.map((c) => ({
email: (c.email || '').trim(),
comment: (c.comment || '').trim(),
enable: c.enable !== false,
}))
.filter((r) => r.email),
[members],
);
// React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setSelectedEmails([]);
setSearch('');
}
}
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) => r.email.toLowerCase().includes(q) || r.comment.toLowerCase().includes(q),
);
}, [rows, search]);
const columns: ColumnsType<ClientRow> = useMemo(
() => [
{ title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
{ title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
{
title: t('enable'),
dataIndex: 'enable',
key: 'enable',
width: 90,
render: (enabled: boolean) =>
enabled ? (
<Tag color="success">{t('enable')}</Tag>
) : (
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
),
},
],
[t],
);
async function submit() {
if (!groupName || selectedEmails.length === 0) return;
setSaving(true);
try {
const result = await onSubmit(selectedEmails);
if (!result) return;
const affected = result.affected ?? selectedEmails.length;
messageApi.success(
t('pages.groups.removeFromGroupResult', { count: affected, name: groupName }),
);
onClose();
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onCancel={onClose}
onOk={submit}
okButtonProps={{ danger: true, disabled: selectedEmails.length === 0, loading: saving }}
okText={t('remove')}
cancelText={t('cancel')}
title={t('pages.groups.removeFromGroupTitle', { name: groupName ?? '' })}
width={680}
>
{messageContextHolder}
<Typography.Paragraph type="secondary">
{t('pages.groups.removeFromGroupDesc')}
</Typography.Paragraph>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search
allowClear
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
style={{ maxWidth: 320 }}
/>
<Typography.Text type="secondary">
{t('pages.inbounds.attachClientsSelectedCount', {
selected: selectedEmails.length,
total: rows.length,
})}
</Typography.Text>
</Space>
<Table<ClientRow>
size="small"
rowKey="email"
columns={columns}
dataSource={filteredRows}
pagination={false}
scroll={{ y: 280 }}
rowSelection={{
selectedRowKeys: selectedEmails,
onChange: (keys) => setSelectedEmails(keys as string[]),
preserveSelectedRowKeys: true,
}}
/>
</Space>
</Modal>
);
}
-708
View File
@@ -1,708 +0,0 @@
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Col,
ConfigProvider,
Dropdown,
Form,
Input,
Layout,
Modal,
Result,
Row,
Space,
Spin,
Statistic,
Table,
Tag,
Tooltip,
message,
} from 'antd';
import type { MenuProps, TableColumnsType } from 'antd';
import {
ArrowDownOutlined,
ArrowUpOutlined,
ClockCircleOutlined,
DeleteOutlined,
EditOutlined,
LinkOutlined,
MoreOutlined,
PieChartOutlined,
PlusOutlined,
RetweetOutlined,
TagsOutlined,
TeamOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
} from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { usePageTitle } from '@/hooks/usePageTitle';
import { useClients } from '@/hooks/useClients';
import { HttpUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility';
import { keys } from '@/api/queryKeys';
import {
ClientRecordSchema,
GroupSummaryListSchema,
type ClientRecord,
type GroupSummary,
} from '@/schemas/client';
import { parseMsg } from '@/utils/zodValidate';
const ClientRecordListSchema = z
.array(ClientRecordSchema)
.nullable()
.transform((v) => v ?? []);
const SubLinksModal = lazy(() => import('../clients/SubLinksModal'));
const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal'));
const GroupAddClientsModal = lazy(() => import('./GroupAddClientsModal'));
const GroupRemoveClientsModal = lazy(() => import('./GroupRemoveClientsModal'));
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
async function fetchGroups(): Promise<GroupSummary[]> {
const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load groups');
const validated = parseMsg(msg, GroupSummaryListSchema, 'clients/groups');
return validated.obj ?? [];
}
async function fetchEmailsForGroup(name: string): Promise<string[]> {
const msg = await HttpUtil.get<string[]>(
`/panel/api/clients/groups/${encodeURIComponent(name)}/emails`,
undefined,
{ silent: true },
);
if (!msg?.success || !Array.isArray(msg.obj)) return [];
return msg.obj;
}
export default function GroupsPage() {
usePageTitle();
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const queryClient = useQueryClient();
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({
list: false,
});
const groupsQuery = useQuery({
queryKey: keys.clients.groups(),
queryFn: fetchGroups,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const loading = groupsQuery.isFetching;
const fetched = groupsQuery.data !== undefined || groupsQuery.isError;
const fetchError = groupsQuery.error ? (groupsQuery.error as Error).message : '';
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: keys.clients.root() });
}, [queryClient]);
const createMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/create', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const renameMut = useMutation({
mutationFn: (body: { oldName: string; newName: string }) =>
HttpUtil.post('/panel/api/clients/groups/rename', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const deleteMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/delete', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const groupResetMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/resetTraffic', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState('');
const [renameOpen, setRenameOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<GroupSummary | null>(null);
const [renameValue, setRenameValue] = useState('');
const [subLinksOpen, setSubLinksOpen] = useState(false);
const [adjustOpen, setAdjustOpen] = useState(false);
const [addClientsOpen, setAddClientsOpen] = useState(false);
const [removeClientsOpen, setRemoveClientsOpen] = useState(false);
const [groupEmails, setGroupEmails] = useState<string[]>([]);
const [groupForAction, setGroupForAction] = useState<GroupSummary | null>(null);
const allClientsQuery = useQuery<ClientRecord[]>({
queryKey: keys.clients.all(),
queryFn: async () => {
const msg = await HttpUtil.get('/panel/api/clients/list', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load clients');
const validated = parseMsg(msg, ClientRecordListSchema, 'clients/list');
return validated.obj ?? [];
},
enabled: addClientsOpen || removeClientsOpen || subLinksOpen,
staleTime: 30_000,
});
const allClients = allClientsQuery.data ?? [];
const totalGroups = groups.length;
const totalClients = useMemo(
() => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
[groups],
);
const totalTraffic = useMemo(
() => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
[groups],
);
const totalUpload = useMemo(() => groups.reduce((acc, g) => acc + (g.up || 0), 0), [groups]);
const totalDownload = useMemo(() => groups.reduce((acc, g) => acc + (g.down || 0), 0), [groups]);
function openCreate() {
setCreateName('');
setCreateOpen(true);
}
async function confirmCreate() {
const name = createName.trim();
if (!name) return;
if (groups.some((g) => g.name.toLowerCase() === name.toLowerCase())) {
messageApi.error(t('pages.groups.renameCollision', { name }));
return;
}
const msg = await createMut.mutateAsync({ name });
if (msg?.success) {
messageApi.success(t('pages.groups.createSuccess', { name }));
setCreateOpen(false);
}
}
function openRename(g: GroupSummary) {
setRenameTarget(g);
setRenameValue(g.name);
setRenameOpen(true);
}
async function confirmRename() {
if (!renameTarget) return;
const next = renameValue.trim();
if (!next || next === renameTarget.name) {
setRenameOpen(false);
return;
}
if (
groups.some(
(g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name,
)
) {
messageApi.error(t('pages.groups.renameCollision', { name: next }));
return;
}
const msg = await renameMut.mutateAsync({ oldName: renameTarget.name, newName: next });
if (msg?.success) {
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
messageApi.success(t('pages.groups.renameSuccess', { count: affected }));
setRenameOpen(false);
}
}
function onDelete(g: GroupSummary) {
modal.confirm({
title: t('pages.groups.deleteConfirmTitle', { name: g.name }),
content: t('pages.groups.deleteConfirmContent', { count: g.clientCount }),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await deleteMut.mutateAsync({ name: g.name });
if (msg?.success) {
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
messageApi.success(t('pages.groups.deleteSuccess', { count: affected }));
}
},
});
}
async function openSubLinksFor(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
const emails = await fetchEmailsForGroup(g.name);
if (emails.length === 0) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
setGroupForAction(g);
setGroupEmails(emails);
setSubLinksOpen(true);
}
async function openAdjustFor(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
const emails = await fetchEmailsForGroup(g.name);
if (emails.length === 0) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
setGroupForAction(g);
setGroupEmails(emails);
setAdjustOpen(true);
}
function openAddClientsFor(g: GroupSummary) {
setGroupForAction(g);
setAddClientsOpen(true);
}
function openRemoveClientsFor(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
setGroupForAction(g);
setRemoveClientsOpen(true);
}
function onDeleteClients(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
modal.confirm({
title: t('pages.groups.deleteClientsConfirmTitle', { name: g.name }),
content: t('pages.groups.deleteClientsConfirmContent', { count: g.clientCount }),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const emails = await fetchEmailsForGroup(g.name);
if (emails.length === 0) return;
const msg = await bulkDelete(emails);
if (msg?.success) {
const ok = msg.obj?.deleted ?? 0;
const skipped = msg.obj?.skipped ?? [];
const failed = skipped.length;
if (failed === 0) {
messageApi.success(t('pages.groups.deleteClientsSuccess', { count: ok }));
} else {
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
messageApi.warning(
firstError
? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}`
: t('pages.groups.deleteClientsMixed', { ok, failed }),
);
}
}
},
});
}
function onResetTraffic(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
modal.confirm({
title: t('pages.groups.resetConfirmTitle', { name: g.name }),
content: t('pages.groups.resetConfirmContent'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await groupResetMut.mutateAsync({ name: g.name });
if (msg?.success) {
messageApi.success(t('pages.groups.resetSuccess', { name: g.name }));
}
},
});
}
function rowActions(row: GroupSummary): MenuProps['items'] {
return [
{
key: 'subLinks',
icon: <LinkOutlined />,
label: t('pages.clients.subLinksSelected', { count: row.clientCount || 0 }),
disabled: !row.clientCount,
onClick: () => openSubLinksFor(row),
},
{
key: 'adjust',
icon: <ClockCircleOutlined />,
label: t('pages.clients.adjustSelected', { count: row.clientCount || 0 }),
disabled: !row.clientCount,
onClick: () => openAdjustFor(row),
},
{
key: 'reset',
icon: <RetweetOutlined />,
label: t('pages.groups.resetTraffic'),
disabled: !row.clientCount,
onClick: () => onResetTraffic(row),
},
{
key: 'addClients',
icon: <UsergroupAddOutlined />,
label: t('pages.groups.addToGroup'),
onClick: () => openAddClientsFor(row),
},
{
key: 'rename',
icon: <EditOutlined />,
label: t('pages.groups.rename'),
onClick: () => openRename(row),
},
{ type: 'divider' },
{
key: 'removeClients',
icon: <UsergroupDeleteOutlined />,
label: t('pages.groups.removeFromGroup'),
danger: true,
disabled: !row.clientCount,
onClick: () => openRemoveClientsFor(row),
},
{
key: 'deleteClients',
icon: <DeleteOutlined />,
label: t('pages.groups.deleteClients'),
danger: true,
disabled: !row.clientCount,
onClick: () => onDeleteClients(row),
},
{
key: 'delete',
icon: <DeleteOutlined />,
label: t('pages.groups.deleteGroupOnly'),
danger: true,
onClick: () => onDelete(row),
},
];
}
const columns: TableColumnsType<GroupSummary> = [
{
title: t('pages.clients.actions'),
key: 'actions',
width: 90,
render: (_v, row) => (
<Space size={4}>
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
<Button
aria-label={t('more')}
size="small"
type="text"
style={{ fontSize: 16 }}
icon={<MoreOutlined />}
/>
</Dropdown>
<Tooltip title={t('pages.groups.rename')}>
<Button
aria-label={t('pages.groups.rename')}
size="small"
type="text"
style={{ fontSize: 16 }}
icon={<EditOutlined />}
onClick={() => openRename(row)}
/>
</Tooltip>
</Space>
),
},
{
title: t('pages.groups.name'),
dataIndex: 'name',
key: 'name',
render: (name: string) => (
<Tag color="geekblue" style={{ margin: 0, fontSize: 13 }}>
{name}
</Tag>
),
},
{
title: t('pages.groups.clientCount'),
dataIndex: 'clientCount',
key: 'clientCount',
width: 180,
render: (count: number) => <span>{count || 0}</span>,
},
{
title: t('pages.groups.upload'),
dataIndex: 'up',
key: 'up',
width: 140,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
{
title: t('pages.groups.download'),
dataIndex: 'down',
key: 'down',
width: 140,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
{
title: t('pages.groups.trafficUsed'),
dataIndex: 'trafficUsed',
key: 'trafficUsed',
width: 160,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
];
const pageClass = useMemo(() => {
const classes = ['groups-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
{!fetched ? (
<div className="loading-spacer" />
) : fetchError ? (
<Result
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={
<Button type="primary" loading={loading} onClick={() => groupsQuery.refetch()}>
{t('refresh')}
</Button>
}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
<Col span={24}>
<Card size="small" hoverable className="summary-card">
<Row gutter={[16, isMobile ? 16 : 12]}>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalGroups')}
value={String(totalGroups)}
prefix={<TagsOutlined />}
/>
</Col>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalGroupedClients')}
value={String(totalClients)}
prefix={<TeamOutlined />}
/>
</Col>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalUpDown')}
value={0}
formatter={() => (
<span>
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totalUpload)}
{' / '}
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totalDownload)}
</span>
)}
/>
</Col>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalTraffic')}
value={SizeFormatter.sizeFormat(totalTraffic)}
prefix={<PieChartOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card
size="small"
hoverable
title={
<div className="card-toolbar">
<Button
aria-label={t('pages.groups.addGroup')}
type="primary"
icon={<PlusOutlined />}
onClick={openCreate}
>
{!isMobile && t('pages.groups.addGroup')}
</Button>
</div>
}
>
<Table<GroupSummary>
dataSource={groups}
columns={columns}
rowKey="name"
size="small"
pagination={false}
loading={loading}
locale={{
emptyText: (
<div className="card-empty">
<TagsOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
/>
</Card>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<Modal
open={createOpen}
title={t('pages.groups.addGroup')}
okText={t('create')}
cancelText={t('cancel')}
confirmLoading={createMut.isPending}
onCancel={() => setCreateOpen(false)}
onOk={confirmCreate}
destroyOnHidden
>
<Form layout="vertical">
<Form.Item label={t('pages.groups.name')}>
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onPressEnter={confirmCreate}
placeholder={t('pages.clients.groupPlaceholder')}
autoFocus
/>
</Form.Item>
</Form>
</Modal>
<Modal
open={renameOpen}
title={renameTarget ? t('pages.groups.renameTitle', { name: renameTarget.name }) : ''}
okText={t('save')}
cancelText={t('cancel')}
confirmLoading={renameMut.isPending}
onCancel={() => setRenameOpen(false)}
onOk={confirmRename}
destroyOnHidden
>
<Form layout="vertical">
<Form.Item label={t('pages.groups.name')}>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onPressEnter={confirmRename}
placeholder={t('pages.clients.groupPlaceholder')}
autoFocus
/>
</Form.Item>
</Form>
</Modal>
<LazyMount when={subLinksOpen}>
<SubLinksModal
open={subLinksOpen}
emails={groupEmails}
clients={allClients}
subSettings={subSettings}
onOpenChange={setSubLinksOpen}
/>
</LazyMount>
<LazyMount when={adjustOpen}>
<ClientBulkAdjustModal
open={adjustOpen}
count={groupEmails.length}
onOpenChange={setAdjustOpen}
onSubmit={async (addDays, addBytes) => {
const msg = await bulkAdjust(groupEmails, addDays, addBytes);
if (msg?.success) {
const obj = msg.obj ?? { adjusted: 0 };
messageApi.success(
t('pages.groups.adjustSuccess', {
count: obj.adjusted ?? 0,
name: groupForAction?.name ?? '',
}),
);
return obj;
}
return null;
}}
/>
</LazyMount>
<LazyMount when={addClientsOpen}>
<GroupAddClientsModal
open={addClientsOpen}
groupName={groupForAction?.name ?? null}
candidates={allClients.filter((c) => c.group !== groupForAction?.name)}
onClose={() => setAddClientsOpen(false)}
onSubmit={async (emails) => {
const msg = await bulkAddToGroup(emails, groupForAction?.name ?? '');
if (msg?.success) {
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={removeClientsOpen}>
<GroupRemoveClientsModal
open={removeClientsOpen}
groupName={groupForAction?.name ?? null}
members={allClients.filter((c) => c.group === groupForAction?.name)}
onClose={() => setRemoveClientsOpen(false)}
onSubmit={async (emails) => {
const msg = await bulkRemoveFromGroup(emails);
if (msg?.success) {
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}
return null;
}}
/>
</LazyMount>
</Layout>
</ConfigProvider>
);
}
@@ -53,7 +53,6 @@ const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal')); const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal')); const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal')); const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
type RowAction = type RowAction =
| 'edit' | 'edit'
@@ -68,7 +67,6 @@ type RowAction =
| 'attachClients' | 'attachClients'
| 'attachExisting' | 'attachExisting'
| 'detachClients' | 'detachClients'
| 'addToGroup'
| 'clone'; | 'clone';
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds'; type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
@@ -165,9 +163,6 @@ export default function InboundsPage() {
const [detachOpen, setDetachOpen] = useState(false); const [detachOpen, setDetachOpen] = useState(false);
const [detachSource, setDetachSource] = useState<DBInbound | null>(null); const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
const [groupOpen, setGroupOpen] = useState(false);
const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
const [cloneOpen, setCloneOpen] = useState(false); const [cloneOpen, setCloneOpen] = useState(false);
const [cloneSource, setCloneSource] = useState<DBInbound | null>(null); const [cloneSource, setCloneSource] = useState<DBInbound | null>(null);
@@ -622,7 +617,6 @@ export default function InboundsPage() {
'clipboard', 'clipboard',
'clone', 'clone',
'attachClients', 'attachClients',
'addToGroup',
]; ];
let target = dbInbound; let target = dbInbound;
if (hydratingKeys.includes(key)) { if (hydratingKeys.includes(key)) {
@@ -672,10 +666,6 @@ export default function InboundsPage() {
setDetachSource(target); setDetachSource(target);
setDetachOpen(true); setDetachOpen(true);
break; break;
case 'addToGroup':
setGroupSource(target);
setGroupOpen(true);
break;
case 'clone': case 'clone':
confirmClone(target); confirmClone(target);
break; break;
@@ -848,14 +838,6 @@ export default function InboundsPage() {
source={detachSource} source={detachSource}
/> />
</LazyMount> </LazyMount>
<LazyMount when={groupOpen}>
<AddClientsToGroupModal
open={groupOpen}
onClose={() => setGroupOpen(false)}
onAdded={refresh}
source={groupSource}
/>
</LazyMount>
<LazyMount when={cloneOpen}> <LazyMount when={cloneOpen}>
<CloneInboundModal <CloneInboundModal
open={cloneOpen} open={cloneOpen}
@@ -1,65 +0,0 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { HttpUtil } from '@/utils';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
const BulkAddToGroupModal = lazy(() => import('@/pages/clients/BulkAddToGroupModal'));
interface AddClientsToGroupModalProps {
open: boolean;
source: DBInbound | null;
onClose: () => void;
onAdded?: () => void;
}
function readClientEmails(settings: unknown): string[] {
const parsed = coerceInboundJsonField(settings) as { clients?: Array<{ email?: string }> };
const clients = Array.isArray(parsed?.clients) ? parsed.clients : [];
return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
}
export default function AddClientsToGroupModal({
open,
source,
onClose,
onAdded,
}: AddClientsToGroupModalProps) {
const [groups, setGroups] = useState<string[]>([]);
const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
if (cancelled) return;
const list = Array.isArray(msg?.obj) ? (msg.obj as Array<{ name?: string }>) : [];
setGroups(list.map((g) => g?.name || '').filter(Boolean));
})();
return () => {
cancelled = true;
};
}, [open]);
return (
<BulkAddToGroupModal
open={open}
count={emails.length}
groups={groups}
onOpenChange={(o) => {
if (!o) onClose();
}}
onSubmit={async (group) => {
const msg = await HttpUtil.post(
'/panel/api/clients/groups/bulkAdd',
{ emails, group },
{ headers: { 'Content-Type': 'application/json' } },
);
if (!msg?.success) return null;
onAdded?.();
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}}
/>
);
}
@@ -91,11 +91,6 @@ export function buildRowActionsMenu({
icon: <UsergroupDeleteOutlined />, icon: <UsergroupDeleteOutlined />,
label: t('pages.inbounds.detachClients'), label: t('pages.inbounds.detachClients'),
}); });
items.push({
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.inbounds.addClientsToGroup'),
});
items.push({ type: 'divider' }); items.push({ type: 'divider' });
items.push({ items.push({
key: 'delAllClients', key: 'delAllClients',
-4
View File
@@ -7,12 +7,10 @@ import PanelLayout from '@/layouts/PanelLayout';
const IndexPage = lazy(() => import('@/pages/index/IndexPage')); const IndexPage = lazy(() => import('@/pages/index/IndexPage'));
const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage')); const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage')); const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
const GroupsPage = lazy(() => import('@/pages/groups/GroupsPage'));
const NodesPage = lazy(() => import('@/pages/nodes/NodesPage')); const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
const HostsPage = lazy(() => import('@/pages/hosts/HostsPage')); const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage')); const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
const XrayPage = lazy(() => import('@/pages/xray/XrayPage')); const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
function withSuspense(node: React.ReactNode) { function withSuspense(node: React.ReactNode) {
return ( return (
@@ -43,14 +41,12 @@ const routes: RouteObject[] = [
{ index: true, element: withSuspense(<IndexPage />) }, { index: true, element: withSuspense(<IndexPage />) },
{ path: 'inbounds', element: withSuspense(<InboundsPage />) }, { path: 'inbounds', element: withSuspense(<InboundsPage />) },
{ path: 'clients', element: withSuspense(<ClientsPage />) }, { path: 'clients', element: withSuspense(<ClientsPage />) },
{ path: 'groups', element: withSuspense(<GroupsPage />) },
{ path: 'nodes', element: withSuspense(<NodesPage />) }, { path: 'nodes', element: withSuspense(<NodesPage />) },
{ path: 'hosts', element: withSuspense(<HostsPage />) }, { path: 'hosts', element: withSuspense(<HostsPage />) },
{ path: 'settings', element: withSuspense(<SettingsPage />) }, { path: 'settings', element: withSuspense(<SettingsPage />) },
{ path: 'xray', element: withSuspense(<XrayPage />) }, { path: 'xray', element: withSuspense(<XrayPage />) },
{ path: 'outbound', element: withSuspense(<XrayPage />) }, { path: 'outbound', element: withSuspense(<XrayPage />) },
{ path: 'routing', element: withSuspense(<XrayPage />) }, { path: 'routing', element: withSuspense(<XrayPage />) },
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
], ],
}, },
]; ];