mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-11 15:16:07 +00:00
feat(inbounds): add multi-select and bulk delete
Mirror the clients page: checkbox selection on the desktop table and on
mobile cards, with a danger Delete button in the toolbar that removes all
selected inbounds in one call.
Backend adds POST /panel/api/inbounds/bulkDel, which loops the existing
DelInbound per id (xray restarts at most once) and returns {deleted,
skipped}. Frontend shows a confirm modal plus a result toast, clears the
selection on success, adds bulk-delete i18n keys across all 13 languages,
and documents the endpoint in the in-panel API docs.
This commit is contained in:
@@ -149,6 +149,13 @@ export const sections: readonly Section[] = [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/bulkDel',
|
||||
summary: 'Delete many inbounds in one call. Processes the list sequentially; failures are reported per id and the rest still proceed. Restarts xray at most once.',
|
||||
body: '{\n "ids": [1, 2, 3]\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "deleted": 2,\n "skipped": [\n { "id": 3, "reason": "..." }\n ]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/update/:id',
|
||||
|
||||
@@ -357,6 +357,36 @@ export default function InboundsPage() {
|
||||
});
|
||||
}, [modal, refresh, t]);
|
||||
|
||||
const confirmBulkDelete = useCallback((ids: number[]) => new Promise<boolean>((resolve) => {
|
||||
if (ids.length === 0) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: t('pages.inbounds.bulkDeleteConfirmTitle', { count: ids.length }),
|
||||
content: t('pages.inbounds.bulkDeleteConfirmContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/bulkDel', { ids }, { headers: { 'Content-Type': 'application/json' } });
|
||||
const obj = (msg?.obj ?? {}) as { deleted?: number; skipped?: { id: number; reason: string }[] };
|
||||
const ok = obj.deleted ?? 0;
|
||||
const skipped = obj.skipped ?? [];
|
||||
if (msg?.success && skipped.length === 0) {
|
||||
messageApi.success(t('pages.inbounds.toasts.bulkDeleted', { count: ok }));
|
||||
} else {
|
||||
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
|
||||
const base = t('pages.inbounds.toasts.bulkDeletedMixed', { ok, failed: skipped.length });
|
||||
messageApi.warning(firstError ? `${base} — ${firstError}` : base);
|
||||
}
|
||||
await refresh();
|
||||
resolve(true);
|
||||
},
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
}), [modal, refresh, t, messageApi]);
|
||||
|
||||
const confirmResetTraffic = useCallback((dbInbound: DBInbound) => {
|
||||
modal.confirm({
|
||||
title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
|
||||
@@ -567,6 +597,7 @@ export default function InboundsPage() {
|
||||
onAddInbound={onAddInbound}
|
||||
onGeneralAction={onGeneralAction}
|
||||
onRowAction={({ key, dbInbound }) => onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })}
|
||||
onBulkDelete={confirmBulkDelete}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -75,6 +75,26 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inbound-card.is-selected {
|
||||
border-color: var(--ant-color-primary);
|
||||
background: color-mix(in srgb, var(--ant-color-primary) 6%, transparent);
|
||||
}
|
||||
|
||||
.card-bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 4px 8px;
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-size: 12px;
|
||||
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||
color: var(--ant-color-primary);
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState, type Key } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
type MenuProps,
|
||||
} from 'antd';
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
ImportOutlined,
|
||||
ReloadOutlined,
|
||||
InfoCircleOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
@@ -43,11 +46,13 @@ export default function InboundList({
|
||||
onAddInbound,
|
||||
onGeneralAction,
|
||||
onRowAction,
|
||||
onBulkDelete,
|
||||
}: InboundListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
|
||||
const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
|
||||
const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
|
||||
const previous = dbInbound.enable;
|
||||
@@ -75,6 +80,26 @@ export default function InboundList({
|
||||
[dbInbounds],
|
||||
);
|
||||
|
||||
const toggleSelect = useCallback((id: number, checked: boolean) => {
|
||||
setSelectedRowKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id); else next.delete(id);
|
||||
return Array.from(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback((checked: boolean) => {
|
||||
setSelectedRowKeys(checked ? sortedInbounds.map((i) => i.id) : []);
|
||||
}, [sortedInbounds]);
|
||||
|
||||
const allSelected = sortedInbounds.length > 0 && selectedRowKeys.length === sortedInbounds.length;
|
||||
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < sortedInbounds.length;
|
||||
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
const ok = await onBulkDelete(selectedRowKeys);
|
||||
if (ok) setSelectedRowKeys([]);
|
||||
}, [onBulkDelete, selectedRowKeys]);
|
||||
|
||||
const columns = useInboundColumns({
|
||||
hasAnyRemark,
|
||||
hasActiveNode,
|
||||
@@ -119,6 +144,16 @@ export default function InboundList({
|
||||
{!isMobile && t('pages.inbounds.generalActions')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<>
|
||||
<Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
|
||||
{t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })}
|
||||
</Tag>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete}>
|
||||
{!isMobile && t('delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
@@ -131,9 +166,26 @@ export default function InboundList({
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
) : (
|
||||
sortedInbounds.map((record) => (
|
||||
<div key={record.id} className="inbound-card">
|
||||
<>
|
||||
<div className="card-bulk-bar">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={(e) => selectAll(e.target.checked)}
|
||||
>
|
||||
{t('pages.inbounds.selectAll')}
|
||||
</Checkbox>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<span className="bulk-count">{selectedRowKeys.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{sortedInbounds.map((record) => (
|
||||
<div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
|
||||
<div className="card-head">
|
||||
<Checkbox
|
||||
checked={selectedRowKeys.includes(record.id)}
|
||||
onChange={(e) => toggleSelect(record.id, e.target.checked)}
|
||||
/>
|
||||
<span className="card-id">#{record.id}</span>
|
||||
<span className="tag-name">{record.remark}</span>
|
||||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -158,7 +210,8 @@ export default function InboundList({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -166,6 +219,10 @@ export default function InboundList({
|
||||
columns={columns}
|
||||
dataSource={sortedInbounds}
|
||||
rowKey={(r) => r.id}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
|
||||
}}
|
||||
pagination={paginationFor(sortedInbounds)}
|
||||
scroll={{ x: 1000 }}
|
||||
style={{ marginTop: 10 }}
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface InboundListProps {
|
||||
onAddInbound: () => void;
|
||||
onGeneralAction: (key: GeneralAction) => void;
|
||||
onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
|
||||
onBulkDelete: (ids: number[]) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export type SortKey =
|
||||
|
||||
Reference in New Issue
Block a user