feat: add batch operations for forwards, tunnels, and nodes

- Add batch delete, redeploy, and change-tunnel for forwards
- Add batch delete and redeploy for tunnels
- Add batch delete for nodes
- Add multi-select UI with floating toolbar on all three pages
- Create DTOs: BatchDeleteDto, BatchRedeployDto, BatchChangeTunnelDto, BatchOperationResultDto
This commit is contained in:
root
2026-02-05 10:59:49 +00:00
parent 02ff215f99
commit 5a1fc808b2
17 changed files with 1107 additions and 74 deletions
+238
View File
@@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner";
import { Switch } from "@heroui/switch";
import { Alert } from "@heroui/alert";
import { Accordion, AccordionItem } from "@heroui/accordion";
import { Checkbox } from "@heroui/checkbox";
import toast from "react-hot-toast";
import {
DndContext,
@@ -47,6 +48,9 @@ import {
resumeForwardService,
diagnoseForward,
updateForwardOrder,
batchDeleteForwards,
batchRedeployForwards,
batchChangeTunnel,
} from "@/api";
import { JwtUtil } from "@/utils/jwt";
@@ -214,6 +218,14 @@ export default function ForwardPage() {
// 表单验证错误
const [errors, setErrors] = useState<{ [key: string]: string }>({});
// 批量操作相关状态
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
const [batchChangeTunnelModalOpen, setBatchChangeTunnelModalOpen] = useState(false);
const [batchTargetTunnelId, setBatchTargetTunnelId] = useState<number | null>(null);
const [batchLoading, setBatchLoading] = useState(false);
useEffect(() => {
loadData();
}, []);
@@ -1260,6 +1272,113 @@ export default function ForwardPage() {
}
};
const toggleSelectMode = () => {
setSelectMode(!selectMode);
if (selectMode) {
setSelectedIds(new Set());
}
};
const toggleSelect = (id: number) => {
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
};
const selectAll = () => {
const allIds = sortedForwards.map((f) => f.id);
setSelectedIds(new Set(allIds));
};
const deselectAll = () => {
setSelectedIds(new Set());
};
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
try {
const res = await batchDeleteForwards(Array.from(selectedIds));
if (res.code === 0) {
const result = res.data;
if (result.failCount === 0) {
toast.success(`成功删除 ${result.successCount} 项`);
} else {
toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`);
}
setSelectedIds(new Set());
setSelectMode(false);
setBatchDeleteModalOpen(false);
loadData(false);
} else {
toast.error(res.msg || "批量删除失败");
}
} catch (e: any) {
toast.error(e.message || "批量删除失败");
} finally {
setBatchLoading(false);
}
};
const handleBatchRedeploy = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
try {
const res = await batchRedeployForwards(Array.from(selectedIds));
if (res.code === 0) {
const result = res.data;
if (result.failCount === 0) {
toast.success(`成功重新下发 ${result.successCount} 项`);
} else {
toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`);
}
setSelectedIds(new Set());
setSelectMode(false);
loadData(false);
} else {
toast.error(res.msg || "批量重新下发失败");
}
} catch (e: any) {
toast.error(e.message || "批量重新下发失败");
} finally {
setBatchLoading(false);
}
};
const handleBatchChangeTunnel = async () => {
if (selectedIds.size === 0 || !batchTargetTunnelId) return;
setBatchLoading(true);
try {
const res = await batchChangeTunnel({
forwardIds: Array.from(selectedIds),
targetTunnelId: batchTargetTunnelId,
});
if (res.code === 0) {
const result = res.data;
if (result.failCount === 0) {
toast.success(`成功换隧道 ${result.successCount} 项`);
} else {
toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`);
}
setSelectedIds(new Set());
setSelectMode(false);
setBatchChangeTunnelModalOpen(false);
setBatchTargetTunnelId(null);
loadData(false);
} else {
toast.error(res.msg || "批量换隧道失败");
}
} catch (e: any) {
toast.error(e.message || "批量换隧道失败");
} finally {
setBatchLoading(false);
}
};
// 传感器配置 - 使用默认配置避免错误
const sensors = useSensors(
useSensor(MouseSensor, {
@@ -1388,6 +1507,13 @@ export default function ForwardPage() {
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
{selectMode && (
<Checkbox
isSelected={selectedIds.has(forward.id)}
onValueChange={() => toggleSelect(forward.id)}
className="mr-2"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">
{forward.name}
@@ -1654,12 +1780,58 @@ export default function ForwardPage() {
导出
</Button>
<Button
size="sm"
variant={selectMode ? "solid" : "flat"}
color={selectMode ? "warning" : "default"}
onPress={toggleSelectMode}
>
{selectMode ? "退出选择" : "批量操作"}
</Button>
<Button size="sm" variant="flat" color="primary" onPress={handleAdd}>
新增
</Button>
</div>
</div>
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 bg-content1 shadow-lg rounded-lg border border-divider p-3 flex items-center gap-3">
<span className="text-sm text-default-600">已选择 {selectedIds.size} 项</span>
<Button size="sm" variant="flat" onPress={selectAll}>
全选
</Button>
<Button size="sm" variant="flat" onPress={deselectAll}>
取消全选
</Button>
<Button
size="sm"
color="danger"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
批量删除
</Button>
<Button
size="sm"
color="primary"
variant="flat"
onPress={handleBatchRedeploy}
isLoading={batchLoading}
>
批量重新下发
</Button>
<Button
size="sm"
color="secondary"
variant="flat"
onPress={() => setBatchChangeTunnelModalOpen(true)}
>
批量换隧道
</Button>
</div>
)}
{/* 根据显示模式渲染不同内容 */}
{viewMode === "grouped" ? (
/* 按用户和隧道分组的转发列表 */
@@ -2891,6 +3063,72 @@ export default function ForwardPage() {
)}
</ModalContent>
</Modal>
{/* 批量删除确认模态框 */}
<Modal isOpen={batchDeleteModalOpen} onOpenChange={setBatchDeleteModalOpen}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>确认批量删除</ModalHeader>
<ModalBody>
<p>确定要删除选中的 {selectedIds.size} 项转发吗?此操作不可撤销。</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="danger"
onPress={handleBatchDelete}
isLoading={batchLoading}
>
确认删除
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 批量换隧道模态框 */}
<Modal isOpen={batchChangeTunnelModalOpen} onOpenChange={setBatchChangeTunnelModalOpen}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>批量换隧道</ModalHeader>
<ModalBody>
<p className="mb-4">将选中的 {selectedIds.size} 项转发迁移到新隧道:</p>
<Select
label="目标隧道"
placeholder="请选择目标隧道"
selectedKeys={batchTargetTunnelId ? [String(batchTargetTunnelId)] : []}
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0];
setBatchTargetTunnelId(selected ? Number(selected) : null);
}}
>
{tunnels.map((tunnel) => (
<SelectItem key={String(tunnel.id)}>{tunnel.name}</SelectItem>
))}
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="primary"
onPress={handleBatchChangeTunnel}
isLoading={batchLoading}
isDisabled={!batchTargetTunnelId}
>
确认换隧道
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
);
}