mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-12 16:15:02 +03:00
1bdb517b88
Ant Design's Spin wraps its content in aria-live="polite", so every WebSocket tick on the dashboard, inbounds, clients and nodes pages was announced, and only the changed number was read, with no label. Pass aria-live="off" on every Spin that wraps content; aria-busy still signals loading. Each vital tile is now a group named by its metric, so "47.2 %" is read as CPU. rc-select renders each tag's remove "x" as an aria-hidden span with no tabIndex, so screen-reader users could only Backspace the last value away, never remove one SNI or inbound from the middle. selectTagRender swaps in a real button named "Remove <value>" and moves focus to the next tag after removal. It is applied to every tags/multiple Select. maxTagCount="responsive" is dropped because it hid selected inbounds behind "+N", where no screen reader could reach them. Two oxlint rules (antd-a11y/spin-content-not-live and antd-a11y/select-tag-remove) keep new code from regressing either fix.
275 lines
9.4 KiB
TypeScript
275 lines
9.4 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Button,
|
|
Card,
|
|
Col,
|
|
ConfigProvider,
|
|
Layout,
|
|
Modal,
|
|
Result,
|
|
Row,
|
|
Spin,
|
|
Statistic,
|
|
message,
|
|
} from 'antd';
|
|
import { CheckCircleOutlined, GlobalOutlined, StopOutlined } from '@ant-design/icons';
|
|
|
|
import { useTheme } from '@/hooks/useTheme';
|
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
|
import { useHostsQuery, type HostRecord } from '@/api/queries/useHostsQuery';
|
|
import { useHostMutations } from '@/api/queries/useHostMutations';
|
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
|
import AppSidebar from '@/layouts/AppSidebar';
|
|
import { setMessageInstance } from '@/utils/messageBus';
|
|
import type { BulkAddHostValues } from '@/schemas/api/host';
|
|
import HostList, { sortHosts } from './HostList';
|
|
import HostFormModal from './HostFormModal';
|
|
|
|
export default function HostsPage() {
|
|
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 { hosts, loading, fetched, fetchError, refetch } = useHostsQuery();
|
|
const { bulkCreate, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } =
|
|
useHostMutations();
|
|
const { data: inboundOptions = [] } = useInboundOptions();
|
|
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
|
const [formHost, setFormHost] = useState<HostRecord | null>(null);
|
|
const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]);
|
|
const [pendingOrder, setPendingOrder] = useState<string[] | null>(null);
|
|
const savingOrder = useRef(false);
|
|
const visibleHosts = useMemo(
|
|
() =>
|
|
pendingOrder
|
|
? hosts.map((h) => ({ ...h, sortOrder: pendingOrder.indexOf(h.groupId) }))
|
|
: hosts,
|
|
[hosts, pendingOrder],
|
|
);
|
|
|
|
const onReorder = useCallback(
|
|
async (groupIds: string[]) => {
|
|
const current = sortHosts(hosts).map((h) => h.groupId);
|
|
if (savingOrder.current || current.every((id, i) => id === groupIds[i])) return;
|
|
savingOrder.current = true;
|
|
setPendingOrder(groupIds);
|
|
try {
|
|
const msg = await reorder(groupIds);
|
|
if (msg?.success) messageApi.success(t('pages.hosts.toasts.update'));
|
|
else messageApi.error(msg?.msg || t('somethingWentWrong'));
|
|
} catch {
|
|
messageApi.error(t('somethingWentWrong'));
|
|
} finally {
|
|
savingOrder.current = false;
|
|
setPendingOrder(null);
|
|
}
|
|
},
|
|
[hosts, reorder, messageApi, t],
|
|
);
|
|
|
|
const onAdd = useCallback(() => {
|
|
setFormMode('add');
|
|
setFormHost(null);
|
|
setFormOpen(true);
|
|
}, []);
|
|
|
|
const onEdit = useCallback((host: HostRecord) => {
|
|
setFormMode('edit');
|
|
setFormHost({ ...host });
|
|
setFormOpen(true);
|
|
}, []);
|
|
|
|
const onSave = useCallback(
|
|
async (payload: BulkAddHostValues) => {
|
|
if (formMode === 'edit' && formHost?.groupId) {
|
|
return update(formHost.groupId, payload);
|
|
}
|
|
return bulkCreate(payload);
|
|
},
|
|
[formMode, formHost, update, bulkCreate],
|
|
);
|
|
|
|
const onDelete = useCallback(
|
|
(host: HostRecord) => {
|
|
modal.confirm({
|
|
title: t('pages.hosts.deleteConfirmTitle', { name: host.remark }),
|
|
okText: t('delete'),
|
|
okType: 'danger',
|
|
cancelText: t('cancel'),
|
|
onOk: async () => {
|
|
const msg = await remove(host.groupId);
|
|
if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete'));
|
|
},
|
|
});
|
|
},
|
|
[modal, t, remove, messageApi],
|
|
);
|
|
|
|
const onToggleEnable = useCallback(
|
|
async (host: HostRecord, next: boolean) => {
|
|
await setEnable(host.groupId, next);
|
|
},
|
|
[setEnable],
|
|
);
|
|
|
|
const onMove = useCallback(
|
|
async (host: HostRecord, dir: 'up' | 'down') => {
|
|
const sorted = sortHosts(hosts);
|
|
const idx = sorted.findIndex((h) => h.groupId === host.groupId);
|
|
const swapWith = dir === 'up' ? idx - 1 : idx + 1;
|
|
if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return;
|
|
const groupIds = sorted.map((h) => h.groupId);
|
|
[groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]];
|
|
await onReorder(groupIds);
|
|
},
|
|
[hosts, onReorder],
|
|
);
|
|
|
|
const onBulkEnable = useCallback(
|
|
async (enable: boolean) => {
|
|
if (selectedGroupIds.length === 0) return;
|
|
const msg = await bulkSetEnable(selectedGroupIds, enable);
|
|
if (msg?.success) setSelectedGroupIds([]);
|
|
},
|
|
[selectedGroupIds, bulkSetEnable],
|
|
);
|
|
|
|
const onBulkDelete = useCallback(() => {
|
|
if (selectedGroupIds.length === 0) return;
|
|
modal.confirm({
|
|
title: t('pages.hosts.bulkDeleteConfirm', { count: selectedGroupIds.length }),
|
|
okText: t('delete'),
|
|
okType: 'danger',
|
|
cancelText: t('cancel'),
|
|
onOk: async () => {
|
|
const msg = await bulkDel(selectedGroupIds);
|
|
if (msg?.success) {
|
|
messageApi.success(t('pages.hosts.toasts.delete'));
|
|
setSelectedGroupIds([]);
|
|
}
|
|
},
|
|
});
|
|
}, [selectedGroupIds, modal, t, bulkDel, messageApi]);
|
|
|
|
const summary = useMemo(() => {
|
|
const total = hosts.length;
|
|
const enabled = hosts.filter((h) => !h.isDisabled).length;
|
|
return { total, enabled, disabled: total - enabled };
|
|
}, [hosts]);
|
|
|
|
const pageClass = useMemo(() => {
|
|
const classes = ['hosts-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 aria-live="off" spinning={!fetched} delay={200} size="large">
|
|
{!fetched ? (
|
|
<div className="loading-spacer" />
|
|
) : fetchError ? (
|
|
<Result
|
|
status="error"
|
|
title={t('somethingWentWrong')}
|
|
subTitle={fetchError}
|
|
extra={
|
|
<Button type="primary" loading={loading} onClick={() => 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, 12]}>
|
|
<Col xs={8} sm={8} md={8}>
|
|
<Statistic
|
|
title={t('pages.hosts.summary.total')}
|
|
value={String(summary.total)}
|
|
prefix={<GlobalOutlined aria-hidden />}
|
|
/>
|
|
</Col>
|
|
<Col xs={8} sm={8} md={8}>
|
|
<Statistic
|
|
title={t('pages.hosts.summary.enabled')}
|
|
value={String(summary.enabled)}
|
|
prefix={
|
|
<CheckCircleOutlined
|
|
aria-hidden
|
|
style={{ color: 'var(--ant-color-success)' }}
|
|
/>
|
|
}
|
|
/>
|
|
</Col>
|
|
<Col xs={8} sm={8} md={8}>
|
|
<Statistic
|
|
title={t('pages.hosts.summary.disabled')}
|
|
value={String(summary.disabled)}
|
|
prefix={
|
|
<StopOutlined
|
|
aria-hidden
|
|
style={{ color: 'var(--ant-color-text-quaternary)' }}
|
|
/>
|
|
}
|
|
/>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
</Col>
|
|
|
|
<Col span={24}>
|
|
<HostList
|
|
hosts={visibleHosts}
|
|
inboundOptions={inboundOptions}
|
|
loading={loading}
|
|
isMobile={isMobile}
|
|
selectedGroupIds={selectedGroupIds}
|
|
onSelectionChange={setSelectedGroupIds}
|
|
onAdd={onAdd}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onToggleEnable={onToggleEnable}
|
|
onMove={onMove}
|
|
onReorder={onReorder}
|
|
reordering={pendingOrder !== null}
|
|
onBulkEnable={onBulkEnable}
|
|
onBulkDelete={onBulkDelete}
|
|
/>
|
|
</Col>
|
|
</Row>
|
|
)}
|
|
</Spin>
|
|
</Layout.Content>
|
|
</Layout>
|
|
|
|
<HostFormModal
|
|
open={formOpen}
|
|
mode={formMode}
|
|
host={formHost}
|
|
inboundOptions={inboundOptions}
|
|
existingHosts={hosts}
|
|
save={onSave}
|
|
onOpenChange={setFormOpen}
|
|
/>
|
|
</Layout>
|
|
</ConfigProvider>
|
|
);
|
|
}
|