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:
MHSanaei
2026-05-31 00:29:24 +02:00
parent 6bb5a3b56b
commit cf50952921
21 changed files with 315 additions and 4 deletions
@@ -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 =