Merge pull request #32 from Sagit-chu/opencode/kind-wolf

fix: improve batch tunnel migration and simplify batch action labels
This commit is contained in:
sagit
2026-02-06 13:36:47 +08:00
committed by GitHub
10 changed files with 231 additions and 105 deletions
-9
View File
@@ -1,9 +0,0 @@
---
active: true
iteration: 1
max_iterations: 100
completion_promise: "DONE"
started_at: "2026-01-24T05:20:50.887Z"
session_id: "ses_41192b7d7ffewFHxf1dDS7ESHO"
---
Complete the task as instructed
@@ -106,6 +106,18 @@ public class ForwardController extends BaseController {
return forwardService.batchDeleteForwards(batchDeleteDto); return forwardService.batchDeleteForwards(batchDeleteDto);
} }
@LogAnnotation
@PostMapping("/batch-pause")
public R batchPause(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
return forwardService.batchPauseForwards(batchDeleteDto);
}
@LogAnnotation
@PostMapping("/batch-resume")
public R batchResume(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
return forwardService.batchResumeForwards(batchDeleteDto);
}
@LogAnnotation @LogAnnotation
@PostMapping("/batch-redeploy") @PostMapping("/batch-redeploy")
public R batchRedeploy(@Validated @RequestBody BatchRedeployDto batchRedeployDto) { public R batchRedeploy(@Validated @RequestBody BatchRedeployDto batchRedeployDto) {
@@ -86,6 +86,10 @@ public interface ForwardService extends IService<Forward> {
R batchDeleteForwards(BatchDeleteDto batchDeleteDto); R batchDeleteForwards(BatchDeleteDto batchDeleteDto);
R batchPauseForwards(BatchDeleteDto batchDeleteDto);
R batchResumeForwards(BatchDeleteDto batchDeleteDto);
R batchRedeployForwards(BatchRedeployDto batchRedeployDto); R batchRedeployForwards(BatchRedeployDto batchRedeployDto);
R batchChangeTunnel(BatchChangeTunnelDto batchChangeTunnelDto); R batchChangeTunnel(BatchChangeTunnelDto batchChangeTunnelDto);
@@ -1304,6 +1304,37 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
return R.ok(result); return R.ok(result);
} }
@Override
@Transactional
public R batchPauseForwards(BatchDeleteDto batchDeleteDto) {
return batchChangeForwardStatus(batchDeleteDto.getIds(), 0, "PauseService");
}
@Override
@Transactional
public R batchResumeForwards(BatchDeleteDto batchDeleteDto) {
return batchChangeForwardStatus(batchDeleteDto.getIds(), 1, "ResumeService");
}
private R batchChangeForwardStatus(List<Long> ids, int targetStatus, String gostMethod) {
BatchOperationResultDto result = new BatchOperationResultDto();
for (Long id : ids) {
try {
R changeResult = changeForwardStatus(id, targetStatus, gostMethod);
if (changeResult.getCode() == 0) {
result.incrementSuccess();
} else {
result.addFailedItem(id, changeResult.getMsg());
}
} catch (Exception e) {
result.addFailedItem(id, e.getMessage());
}
}
return R.ok(result);
}
@Override @Override
@Transactional @Transactional
public R batchRedeployForwards(BatchRedeployDto batchRedeployDto) { public R batchRedeployForwards(BatchRedeployDto batchRedeployDto) {
@@ -1385,6 +1416,15 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
continue; continue;
} }
List<ForwardPort> existingForwardPorts = forwardPortService.list(
new QueryWrapper<ForwardPort>().eq("forward_id", forwardId).orderByAsc("id")
);
Integer originalInPort = existingForwardPorts.stream()
.map(ForwardPort::getPort)
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
Tunnel oldTunnel = validateTunnel(forward.getTunnelId()); Tunnel oldTunnel = validateTunnel(forward.getTunnelId());
if (oldTunnel != null) { if (oldTunnel != null) {
UserTunnel oldUserTunnel = null; UserTunnel oldUserTunnel = null;
@@ -1424,7 +1464,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
List<ChainTunnel> newChainTunnels = chainTunnelService.list( List<ChainTunnel> newChainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", targetTunnel.getId()).eq("chain_type", 1) new QueryWrapper<ChainTunnel>().eq("tunnel_id", targetTunnel.getId()).eq("chain_type", 1)
); );
List<ChainTunnel> chainTunnelsWithPort = get_port(newChainTunnels, null, forwardId); List<ChainTunnel> chainTunnelsWithPort = allocatePortsForBatchTunnelChange(newChainTunnels, originalInPort, forwardId);
for (ChainTunnel chainTunnel : chainTunnelsWithPort) { for (ChainTunnel chainTunnel : chainTunnelsWithPort) {
ForwardPort forwardPort = new ForwardPort(); ForwardPort forwardPort = new ForwardPort();
@@ -1450,4 +1490,23 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
return R.ok(result); return R.ok(result);
} }
private List<ChainTunnel> allocatePortsForBatchTunnelChange(List<ChainTunnel> newChainTunnels, Integer originalInPort, Long forwardId) {
if (originalInPort == null) {
return get_port(newChainTunnels, null, forwardId);
}
try {
return get_port(newChainTunnels, originalInPort, forwardId);
} catch (RuntimeException originalPortError) {
try {
return get_port(newChainTunnels, null, forwardId);
} catch (RuntimeException autoAllocateError) {
throw new RuntimeException(
"原入口端口 " + originalInPort + " 在目标隧道不可用,自动分配新端口也失败: " + autoAllocateError.getMessage(),
autoAllocateError
);
}
}
}
} }
+4 -4
View File
@@ -55,7 +55,7 @@
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-is": "^19.2.4", "react-is": "^19.2.4",
"react-router-dom": "6.23.0", "react-router-dom": "6.30.3",
"recharts": "^3.1.1", "recharts": "^3.1.1",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-variants": "1.0.0", "tailwind-variants": "1.0.0",
@@ -64,14 +64,14 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "1.2.8", "@eslint/compat": "1.2.8",
"@eslint/eslintrc": "3.3.1", "@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.25.1", "@eslint/js": "9.39.2",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"@types/react": "18.3.3", "@types/react": "18.3.3",
"@types/react-dom": "18.3.0", "@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.31.1", "@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1", "@typescript-eslint/parser": "8.31.1",
"@vitejs/plugin-react": "4.4.1", "@vitejs/plugin-react": "4.4.1",
"eslint": "9.25.1", "eslint": "9.39.2",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
@@ -84,7 +84,7 @@
"postcss": "8.5.6", "postcss": "8.5.6",
"prettier": "3.5.3", "prettier": "3.5.3",
"typescript": "5.6.3", "typescript": "5.6.3",
"vite": "5.4.11", "vite": "5.4.21",
"vite-tsconfig-paths": "4.3.2" "vite-tsconfig-paths": "4.3.2"
} }
} }
+4
View File
@@ -131,6 +131,10 @@ export const verifyCaptcha = (data: { captchaId: string; trackData: string }) =>
// 批量操作接口 // 批量操作接口
export const batchDeleteForwards = (ids: number[]) => export const batchDeleteForwards = (ids: number[]) =>
Network.post("/forward/batch-delete", { ids }); Network.post("/forward/batch-delete", { ids });
export const batchPauseForwards = (ids: number[]) =>
Network.post("/forward/batch-pause", { ids });
export const batchResumeForwards = (ids: number[]) =>
Network.post("/forward/batch-resume", { ids });
export const batchDeleteTunnels = (ids: number[]) => export const batchDeleteTunnels = (ids: number[]) =>
Network.post("/tunnel/batch-delete", { ids }); Network.post("/tunnel/batch-delete", { ids });
export const batchDeleteNodes = (ids: number[]) => export const batchDeleteNodes = (ids: number[]) =>
+7 -7
View File
@@ -62,7 +62,7 @@ export default function AdminLayout({
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ {
path: "/dashboard", path: "/dashboard",
label: "仪表板", label: "仪表",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" /> <path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
@@ -71,7 +71,7 @@ export default function AdminLayout({
}, },
{ {
path: "/forward", path: "/forward",
label: "转发管理", label: "转发",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path <path
@@ -84,7 +84,7 @@ export default function AdminLayout({
}, },
{ {
path: "/tunnel", path: "/tunnel",
label: "隧道管理", label: "隧道",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path <path
@@ -98,7 +98,7 @@ export default function AdminLayout({
}, },
{ {
path: "/node", path: "/node",
label: "节点监控", label: "节点",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path <path
@@ -112,7 +112,7 @@ export default function AdminLayout({
}, },
{ {
path: "/limit", path: "/limit",
label: "限速管理", label: "限速",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path <path
@@ -126,7 +126,7 @@ export default function AdminLayout({
}, },
{ {
path: "/user", path: "/user",
label: "用户管理", label: "用户",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" /> <path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
@@ -136,7 +136,7 @@ export default function AdminLayout({
}, },
{ {
path: "/config", path: "/config",
label: "网站配置", label: "设置",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path <path
+98 -44
View File
@@ -49,6 +49,8 @@ import {
diagnoseForward, diagnoseForward,
updateForwardOrder, updateForwardOrder,
batchDeleteForwards, batchDeleteForwards,
batchPauseForwards,
batchResumeForwards,
batchRedeployForwards, batchRedeployForwards,
batchChangeTunnel, batchChangeTunnel,
} from "@/api"; } from "@/api";
@@ -1315,10 +1317,42 @@ export default function ForwardPage() {
setBatchDeleteModalOpen(false); setBatchDeleteModalOpen(false);
loadData(false); loadData(false);
} else { } else {
toast.error(res.msg || "批量删除失败"); toast.error(res.msg || "删除失败");
} }
} catch (e: any) { } catch (e: any) {
toast.error(e.message || "批量删除失败"); toast.error(e.message || "删除失败");
} finally {
setBatchLoading(false);
}
};
const handleBatchToggleService = async (enable: boolean) => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
try {
const ids = Array.from(selectedIds);
const res = enable
? await batchResumeForwards(ids)
: await batchPauseForwards(ids);
if (res.code === 0) {
const result = res.data;
if (result.failCount === 0) {
toast.success(
enable
? `成功启用 ${result.successCount} 项`
: `成功停用 ${result.successCount} 项`,
);
} else {
toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`);
}
setSelectedIds(new Set());
setSelectMode(false);
loadData(false);
} else {
toast.error(res.msg || (enable ? "启用失败" : "停用失败"));
}
} catch (e: any) {
toast.error(e.message || (enable ? "启用失败" : "停用失败"));
} finally { } finally {
setBatchLoading(false); setBatchLoading(false);
} }
@@ -1340,10 +1374,10 @@ export default function ForwardPage() {
setSelectMode(false); setSelectMode(false);
loadData(false); loadData(false);
} else { } else {
toast.error(res.msg || "批量重新下发失败"); toast.error(res.msg || "下发失败");
} }
} catch (e: any) { } catch (e: any) {
toast.error(e.message || "批量重新下发失败"); toast.error(e.message || "下发失败");
} finally { } finally {
setBatchLoading(false); setBatchLoading(false);
} }
@@ -1370,10 +1404,10 @@ export default function ForwardPage() {
setBatchTargetTunnelId(null); setBatchTargetTunnelId(null);
loadData(false); loadData(false);
} else { } else {
toast.error(res.msg || "批量换隧道失败"); toast.error(res.msg || "隧道失败");
} }
} catch (e: any) { } catch (e: any) {
toast.error(e.message || "批量换隧道失败"); toast.error(e.message || "隧道失败");
} finally { } finally {
setBatchLoading(false); setBatchLoading(false);
} }
@@ -1731,9 +1765,9 @@ export default function ForwardPage() {
return ( return (
<div className="px-3 lg:px-6 py-8"> <div className="px-3 lg:px-6 py-8">
{/* 页面头部 */} {/* 页面头部 */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6 gap-2">
<div className="flex-1"></div> <div className="flex-1"></div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
{/* 显示模式切换按钮 */} {/* 显示模式切换按钮 */}
<Button <Button
isIconOnly isIconOnly
@@ -1786,7 +1820,7 @@ export default function ForwardPage() {
color={selectMode ? "warning" : "default"} color={selectMode ? "warning" : "default"}
onPress={toggleSelectMode} onPress={toggleSelectMode}
> >
{selectMode ? "退出选择" : "批量操作"} {selectMode ? "退出" : "批量"}
</Button> </Button>
<Button size="sm" variant="flat" color="primary" onPress={handleAdd}> <Button size="sm" variant="flat" color="primary" onPress={handleAdd}>
@@ -1796,39 +1830,59 @@ export default function ForwardPage() {
</div> </div>
{selectMode && selectedIds.size > 0 && ( {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"> <div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<span className="text-sm text-default-600">已选择 {selectedIds.size} 项</span> <div className="flex min-w-max items-center gap-2">
<Button size="sm" variant="flat" onPress={selectAll}> <span className="text-sm text-default-600 shrink-0">已选择 {selectedIds.size} 项</span>
全选 <Button size="sm" variant="flat" onPress={selectAll}>
</Button> 全选
<Button size="sm" variant="flat" onPress={deselectAll}> </Button>
取消全选 <Button size="sm" variant="flat" onPress={deselectAll}>
</Button> 清空
<Button </Button>
size="sm" <Button
color="danger" size="sm"
variant="flat" color="danger"
onPress={() => setBatchDeleteModalOpen(true)} variant="flat"
> onPress={() => setBatchDeleteModalOpen(true)}
批量删除 >
</Button> 删除
<Button </Button>
size="sm" <Button
color="primary" size="sm"
variant="flat" color="warning"
onPress={handleBatchRedeploy} variant="flat"
isLoading={batchLoading} onPress={() => handleBatchToggleService(false)}
> isLoading={batchLoading}
批量重新下发 >
</Button> 停用
<Button </Button>
size="sm" <Button
color="secondary" size="sm"
variant="flat" color="success"
onPress={() => setBatchChangeTunnelModalOpen(true)} variant="flat"
> onPress={() => handleBatchToggleService(true)}
批量换隧道 isLoading={batchLoading}
</Button> >
启用
</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>
</div> </div>
)} )}
@@ -3069,7 +3123,7 @@ export default function ForwardPage() {
<ModalContent> <ModalContent>
{(onClose) => ( {(onClose) => (
<> <>
<ModalHeader>确认批量删除</ModalHeader> <ModalHeader>确认删除</ModalHeader>
<ModalBody> <ModalBody>
<p>确定要删除选中的 {selectedIds.size} 项转发吗?此操作不可撤销。</p> <p>确定要删除选中的 {selectedIds.size} 项转发吗?此操作不可撤销。</p>
</ModalBody> </ModalBody>
@@ -3095,7 +3149,7 @@ export default function ForwardPage() {
<ModalContent> <ModalContent>
{(onClose) => ( {(onClose) => (
<> <>
<ModalHeader>批量换隧道</ModalHeader> <ModalHeader>隧道</ModalHeader>
<ModalBody> <ModalBody>
<p className="mb-4">将选中的 {selectedIds.size} 项转发迁移到新隧道:</p> <p className="mb-4">将选中的 {selectedIds.size} 项转发迁移到新隧道:</p>
<Select <Select
+9 -9
View File
@@ -939,7 +939,7 @@ export default function NodePage() {
setBatchDeleteModalOpen(false); setBatchDeleteModalOpen(false);
setSelectMode(false); setSelectMode(false);
} else { } else {
toast.error(res.msg || "批量删除失败"); toast.error(res.msg || "删除失败");
} }
} catch (error) { } catch (error) {
toast.error("网络错误,请重试"); toast.error("网络错误,请重试");
@@ -1032,16 +1032,16 @@ export default function NodePage() {
{/* 批量操作浮动工具栏 */} {/* 批量操作浮动工具栏 */}
{selectMode && selectedIds.size > 0 && ( {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"> <div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<span className="text-sm font-medium"> <div className="flex min-w-max items-center gap-2">
已选 {selectedIds.size} 项 <span className="text-sm font-medium shrink-0">
</span> 已选 {selectedIds.size} 项
<div className="flex gap-2"> </span>
<Button size="sm" variant="flat" onPress={selectAll}> <Button size="sm" variant="flat" onPress={selectAll}>
全选 全选
</Button> </Button>
<Button size="sm" variant="flat" onPress={deselectAll}> <Button size="sm" variant="flat" onPress={deselectAll}>
取消全选 清空
</Button> </Button>
<Button <Button
color="danger" color="danger"
@@ -1049,7 +1049,7 @@ export default function NodePage() {
variant="flat" variant="flat"
onPress={() => setBatchDeleteModalOpen(true)} onPress={() => setBatchDeleteModalOpen(true)}
> >
批量删除 删除
</Button> </Button>
</div> </div>
</div> </div>
@@ -1814,7 +1814,7 @@ export default function NodePage() {
{(onClose) => ( {(onClose) => (
<> <>
<ModalHeader className="flex flex-col gap-1"> <ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">确认批量删除</h2> <h2 className="text-xl font-bold">确认删除</h2>
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<p> <p>
+33 -31
View File
@@ -696,10 +696,10 @@ export default function TunnelPage() {
setBatchDeleteModalOpen(false); setBatchDeleteModalOpen(false);
loadData(); loadData();
} else { } else {
toast.error(res.msg || "批量删除失败"); toast.error(res.msg || "删除失败");
} }
} catch (e: any) { } catch (e: any) {
toast.error(e.message || "批量删除失败"); toast.error(e.message || "删除失败");
} finally { } finally {
setBatchLoading(false); setBatchLoading(false);
} }
@@ -721,10 +721,10 @@ export default function TunnelPage() {
setSelectMode(false); setSelectMode(false);
loadData(); loadData();
} else { } else {
toast.error(res.msg || "批量重新下发失败"); toast.error(res.msg || "下发失败");
} }
} catch (e: any) { } catch (e: any) {
toast.error(e.message || "批量重新下发失败"); toast.error(e.message || "下发失败");
} finally { } finally {
setBatchLoading(false); setBatchLoading(false);
} }
@@ -845,7 +845,7 @@ export default function TunnelPage() {
color={selectMode ? "warning" : "default"} color={selectMode ? "warning" : "default"}
onPress={toggleSelectMode} onPress={toggleSelectMode}
> >
{selectMode ? "退出选择" : "批量操作"} {selectMode ? "退出" : "批量"}
</Button> </Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}> <Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增 新增
@@ -854,31 +854,33 @@ export default function TunnelPage() {
</div> </div>
{selectMode && selectedIds.size > 0 && ( {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"> <div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<span className="text-sm text-default-600">已选择 {selectedIds.size} 项</span> <div className="flex min-w-max items-center gap-2">
<Button size="sm" variant="flat" onPress={selectAll}> <span className="text-sm text-default-600 shrink-0">已选择 {selectedIds.size} 项</span>
全选 <Button size="sm" variant="flat" onPress={selectAll}>
</Button> 全选
<Button size="sm" variant="flat" onPress={deselectAll}> </Button>
取消全选 <Button size="sm" variant="flat" onPress={deselectAll}>
</Button> 清空
<Button </Button>
size="sm" <Button
color="danger" size="sm"
variant="flat" color="danger"
onPress={() => setBatchDeleteModalOpen(true)} variant="flat"
> onPress={() => setBatchDeleteModalOpen(true)}
批量删除 >
</Button> 删除
<Button </Button>
size="sm" <Button
color="primary" size="sm"
variant="flat" color="primary"
onPress={handleBatchRedeploy} variant="flat"
isLoading={batchLoading} onPress={handleBatchRedeploy}
> isLoading={batchLoading}
批量重新下发 >
</Button> 下发
</Button>
</div>
</div> </div>
)} )}
@@ -2514,7 +2516,7 @@ export default function TunnelPage() {
<ModalContent> <ModalContent>
{(onClose) => ( {(onClose) => (
<> <>
<ModalHeader>确认批量删除</ModalHeader> <ModalHeader>确认删除</ModalHeader>
<ModalBody> <ModalBody>
<p>确定要删除选中的 {selectedIds.size} 项隧道吗?此操作不可撤销,相关转发也将被删除。</p> <p>确定要删除选中的 {selectedIds.size} 项隧道吗?此操作不可撤销,相关转发也将被删除。</p>
</ModalBody> </ModalBody>