mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-06 01:56:37 +08:00
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:
@@ -127,3 +127,19 @@ export const checkCaptcha = () => Network.post("/captcha/check");
|
||||
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
||||
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) =>
|
||||
Network.post("/captcha/verify", data);
|
||||
|
||||
// 批量操作接口
|
||||
export const batchDeleteForwards = (ids: number[]) =>
|
||||
Network.post("/forward/batch-delete", { ids });
|
||||
export const batchDeleteTunnels = (ids: number[]) =>
|
||||
Network.post("/tunnel/batch-delete", { ids });
|
||||
export const batchDeleteNodes = (ids: number[]) =>
|
||||
Network.post("/node/batch-delete", { ids });
|
||||
export const batchRedeployForwards = (ids: number[]) =>
|
||||
Network.post("/forward/batch-redeploy", { ids });
|
||||
export const batchRedeployTunnels = (ids: number[]) =>
|
||||
Network.post("/tunnel/batch-redeploy", { ids });
|
||||
export const batchChangeTunnel = (data: {
|
||||
forwardIds: number[];
|
||||
targetTunnelId: number;
|
||||
}) => Network.post("/forward/batch-change-tunnel", data);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner";
|
||||
import { Alert } from "@heroui/alert";
|
||||
import { Progress } from "@heroui/progress";
|
||||
import { Accordion, AccordionItem } from "@heroui/accordion";
|
||||
import { Checkbox } from "@heroui/checkbox";
|
||||
import toast from "react-hot-toast";
|
||||
import axios from "axios";
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
deleteNode,
|
||||
getNodeInstallCommand,
|
||||
updateNodeOrder,
|
||||
batchDeleteNodes,
|
||||
} from "@/api";
|
||||
|
||||
interface Node {
|
||||
@@ -150,6 +152,11 @@ export default function NodePage() {
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
// 安装命令相关状态
|
||||
const [installCommandModal, setInstallCommandModal] = useState(false);
|
||||
const [installCommand, setInstallCommand] = useState("");
|
||||
@@ -890,6 +897,57 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 批量操作处理函数
|
||||
const toggleSelectMode = () => {
|
||||
setSelectMode((prev) => {
|
||||
if (prev) {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedIds(new Set(sortedNodes.map((n) => n.id)));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await batchDeleteNodes(Array.from(selectedIds));
|
||||
if (res.code === 0) {
|
||||
toast.success(`成功删除 ${selectedIds.size} 个节点`);
|
||||
setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id)));
|
||||
setSelectedIds(new Set());
|
||||
setBatchDeleteModalOpen(false);
|
||||
setSelectMode(false);
|
||||
} else {
|
||||
toast.error(res.msg || "批量删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("网络错误,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 传感器配置
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
@@ -957,11 +1015,46 @@ export default function NodePage() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
color={selectMode ? "warning" : "default"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
{selectMode ? "取消多选" : "多选"}
|
||||
</Button>
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作浮动工具栏 */}
|
||||
{selectMode && selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-content1 shadow-lg rounded-full px-6 py-3 flex items-center gap-4 border border-divider">
|
||||
<span className="text-sm font-medium">
|
||||
已选 {selectedIds.size} 项
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="flat" onPress={selectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="sm" variant="flat" onPress={deselectAll}>
|
||||
取消全选
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setBatchDeleteModalOpen(true)}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!wsConnected && (
|
||||
<Alert
|
||||
className="mb-4"
|
||||
@@ -1027,7 +1120,13 @@ export default function NodePage() {
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{selectMode && (
|
||||
<Checkbox
|
||||
isSelected={selectedIds.has(node.id)}
|
||||
onValueChange={() => toggleSelect(node.id)}
|
||||
/>
|
||||
)}
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||
{node.name}
|
||||
</h3>
|
||||
@@ -1701,6 +1800,46 @@ export default function NodePage() {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 批量删除确认模态框 */}
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
isOpen={batchDeleteModalOpen}
|
||||
placement="center"
|
||||
scrollBehavior="outside"
|
||||
size="md"
|
||||
onOpenChange={setBatchDeleteModalOpen}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">确认批量删除</h2>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>
|
||||
确定要删除选中的 <strong>{selectedIds.size}</strong> 个节点吗?
|
||||
</p>
|
||||
<p className="text-small text-default-500">
|
||||
此操作不可恢复,请谨慎操作。
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
isLoading={batchLoading}
|
||||
onPress={handleBatchDelete}
|
||||
>
|
||||
{batchLoading ? "删除中..." : "确认删除"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Chip } from "@heroui/chip";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Alert } from "@heroui/alert";
|
||||
import { Checkbox } from "@heroui/checkbox";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -41,6 +42,8 @@ import {
|
||||
getNodeList,
|
||||
diagnoseTunnel,
|
||||
updateTunnelOrder,
|
||||
batchDeleteTunnels,
|
||||
batchRedeployTunnels,
|
||||
} from "@/api";
|
||||
|
||||
interface ChainTunnel {
|
||||
@@ -144,6 +147,12 @@ export default function TunnelPage() {
|
||||
// 表单验证错误
|
||||
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 [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
@@ -644,6 +653,83 @@ export default function TunnelPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 = sortedTunnels.map((t) => t.id);
|
||||
setSelectedIds(new Set(allIds));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await batchDeleteTunnels(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();
|
||||
} 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 batchRedeployTunnels(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();
|
||||
} else {
|
||||
toast.error(res.msg || "批量重新下发失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || "批量重新下发失败");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 传感器配置
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
@@ -752,11 +838,50 @@ export default function TunnelPage() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectMode ? "solid" : "flat"}
|
||||
color={selectMode ? "warning" : "default"}
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
{selectMode ? "退出选择" : "批量操作"}
|
||||
</Button>
|
||||
<Button color="primary" size="sm" variant="flat" 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 隧道卡片网格 */}
|
||||
{tunnels.length > 0 ? (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
@@ -777,6 +902,13 @@ export default function TunnelPage() {
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
{selectMode && (
|
||||
<Checkbox
|
||||
isSelected={selectedIds.has(tunnel.id)}
|
||||
onValueChange={() => toggleSelect(tunnel.id)}
|
||||
className="mr-2"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||
{tunnel.name}
|
||||
@@ -2377,6 +2509,31 @@ export default function TunnelPage() {
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user