Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f637a9b92 |
@@ -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
@@ -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;
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 />) },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user