fix(frontend): keep mobile batch toolbar right-aligned (#203)

## Summary
- keep mobile batch action bars visually right-aligned while preserving
Android horizontal swipe behavior
- move overflow handling to an outer scroller and keep action layout in
an inner `min-w-full` flex row
- apply the same structure across forward, node, and tunnel pages for
consistent behavior

## Verification
- `npm run build` *(fails due to pre-existing issue: `src/main.tsx`
cannot find module `virtual:pwa-register`)*
- `npm run lint -- src/pages/forward.tsx src/pages/node.tsx
src/pages/tunnel.tsx` *(fails due to pre-existing a11y labels in
`forward.tsx` lines 3202/3223)*
This commit is contained in:
sagit
2026-02-23 22:55:49 +08:00
committed by GitHub
3 changed files with 335 additions and 329 deletions
+189 -187
View File
@@ -1722,205 +1722,207 @@ export default function ForwardPage() {
onOpen={() => setIsSearchVisible(true)} onOpen={() => setIsSearchVisible(true)}
/> />
</div> </div>
<div className="flex min-h-9 min-w-0 max-w-full items-center justify-start gap-2 overflow-x-auto whitespace-nowrap touch-pan-x sm:justify-end sm:gap-3 [&>*]:shrink-0"> <div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
{selectMode ? ( <div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap sm:gap-3 [&>*]:shrink-0">
<> {selectMode ? (
<span className="text-sm text-default-600 shrink-0"> <>
已选择 {selectedIds.size} 项 <span className="text-sm text-default-600 shrink-0">
</span> 已选择 {selectedIds.size} 项
<Button </span>
color="primary" <Button
size="sm" color="primary"
variant="flat" size="sm"
onPress={selectAll} variant="flat"
> onPress={selectAll}
全选 >
</Button> 全选
<Button </Button>
color="secondary" <Button
size="sm" color="secondary"
variant="flat" size="sm"
onPress={deselectAll} variant="flat"
> onPress={deselectAll}
清空 >
</Button> 清空
<Button </Button>
color="danger" <Button
isDisabled={selectedIds.size === 0} color="danger"
size="sm" isDisabled={selectedIds.size === 0}
variant="flat" size="sm"
onPress={() => setBatchDeleteModalOpen(true)} variant="flat"
> onPress={() => setBatchDeleteModalOpen(true)}
删除 >
</Button> 删除
<Button </Button>
color="warning" <Button
isDisabled={selectedIds.size === 0} color="warning"
isLoading={batchLoading} isDisabled={selectedIds.size === 0}
size="sm" isLoading={batchLoading}
variant="flat" size="sm"
onPress={() => handleBatchToggleService(false)} variant="flat"
> onPress={() => handleBatchToggleService(false)}
停用 >
</Button> 停用
<Button </Button>
color="success" <Button
isDisabled={selectedIds.size === 0} color="success"
isLoading={batchLoading} isDisabled={selectedIds.size === 0}
size="sm" isLoading={batchLoading}
variant="flat" size="sm"
onPress={() => handleBatchToggleService(true)} variant="flat"
> onPress={() => handleBatchToggleService(true)}
启用 >
</Button> 启用
<Button </Button>
color="primary" <Button
isDisabled={selectedIds.size === 0} color="primary"
isLoading={batchLoading} isDisabled={selectedIds.size === 0}
size="sm" isLoading={batchLoading}
variant="flat" size="sm"
onPress={handleBatchRedeploy} variant="flat"
> onPress={handleBatchRedeploy}
下发 >
</Button> 下发
<Button </Button>
color="secondary" <Button
isDisabled={selectedIds.size === 0} color="secondary"
size="sm" isDisabled={selectedIds.size === 0}
variant="flat" size="sm"
onPress={() => setBatchChangeTunnelModalOpen(true)} variant="flat"
> onPress={() => setBatchChangeTunnelModalOpen(true)}
隧道 >
</Button> 隧道
<Button </Button>
color="secondary" <Button
size="sm" color="secondary"
variant="solid" size="sm"
onPress={toggleSelectMode} variant="solid"
> onPress={toggleSelectMode}
退出 >
</Button> 退出
</> </Button>
) : ( </>
<> ) : (
{/* 筛选按钮 */} <>
<Button {/* 筛选按钮 */}
isIconOnly <Button
aria-label="筛选条件" isIconOnly
className={ aria-label="筛选条件"
filterUserId !== "all" || filterTunnelId !== "all" className={
? "bg-primary/20 text-primary relative" filterUserId !== "all" || filterTunnelId !== "all"
: "text-default-600 relative" ? "bg-primary/20 text-primary relative"
} : "text-default-600 relative"
color={ }
filterUserId !== "all" || filterTunnelId !== "all" color={
? "primary" filterUserId !== "all" || filterTunnelId !== "all"
: "default" ? "primary"
} : "default"
size="sm" }
title="筛选条件" size="sm"
variant="flat" title="筛选条件"
onPress={() => setIsFilterModalOpen(true)} variant="flat"
> onPress={() => setIsFilterModalOpen(true)}
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
{(filterUserId !== "all" || filterTunnelId !== "all") && (
<span className="absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary" />
)}
</Button>
{/* 显示模式切换按钮 */}
<Button
isIconOnly
aria-label={
viewMode === "grouped" ? "切换到直接显示" : "切换到分类显示"
}
className="text-sm"
color="default"
size="sm"
title={
viewMode === "grouped" ? "切换到直接显示" : "切换到分类显示"
}
variant="flat"
onPress={handleViewModeChange}
>
{viewMode === "grouped" ? (
<svg <svg
aria-hidden="true" aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="currentColor" fill="none"
viewBox="0 0 20 20" stroke="currentColor"
viewBox="0 0 24 24"
> >
<path <path
clipRule="evenodd" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
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-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2zM3 16a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z" strokeLinecap="round"
fillRule="evenodd" strokeLinejoin="round"
strokeWidth={2}
/> />
</svg> </svg>
) : ( {(filterUserId !== "all" || filterTunnelId !== "all") && (
<svg <span className="absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary" />
aria-hidden="true" )}
className="w-4 h-4" </Button>
fill="currentColor" {/* 显示模式切换按钮 */}
viewBox="0 0 20 20" <Button
> isIconOnly
<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" /> aria-label={
</svg> viewMode === "grouped" ? "切换到直接显示" : "切换到分类显示"
)} }
</Button> className="text-sm"
color="default"
size="sm"
title={
viewMode === "grouped" ? "切换到直接显示" : "切换到分类显示"
}
variant="flat"
onPress={handleViewModeChange}
>
{viewMode === "grouped" ? (
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
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-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2zM3 16a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z"
fillRule="evenodd"
/>
</svg>
) : (
<svg
aria-hidden="true"
className="w-4 h-4"
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" />
</svg>
)}
</Button>
{/* 导入按钮 */} {/* 导入按钮 */}
<Button <Button
color="warning" color="warning"
size="sm" size="sm"
variant="flat" variant="flat"
onPress={handleImport} onPress={handleImport}
> >
导入 导入
</Button> </Button>
{/* 导出按钮 */} {/* 导出按钮 */}
<Button <Button
color="success" color="success"
isLoading={exportLoading} isLoading={exportLoading}
size="sm" size="sm"
variant="flat" variant="flat"
onPress={handleExport} onPress={handleExport}
> >
导出 导出
</Button> </Button>
<Button <Button
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45" className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
color="default" color="default"
size="sm" size="sm"
variant="flat" variant="flat"
onPress={toggleSelectMode} onPress={toggleSelectMode}
> >
批量 批量
</Button> </Button>
<Button <Button
color="primary" color="primary"
size="sm" size="sm"
variant="flat" variant="flat"
onPress={handleAdd} onPress={handleAdd}
> >
新增 新增
</Button> </Button>
</> </>
)} )}
</div>
</div> </div>
</div> </div>
+73 -71
View File
@@ -1075,77 +1075,79 @@ export default function NodePage() {
/> />
</div> </div>
<div className="flex min-h-9 min-w-0 max-w-full items-center justify-start gap-2 overflow-x-auto whitespace-nowrap touch-pan-x sm:justify-end [&>*]:shrink-0"> <div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
{selectMode ? ( <div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
<> {selectMode ? (
<span className="text-sm text-default-600 shrink-0"> <>
已选 {selectedIds.size} 项 <span className="text-sm text-default-600 shrink-0">
</span> 已选 {selectedIds.size} 项
<Button </span>
color="primary" <Button
size="sm" color="primary"
variant="flat" size="sm"
onPress={selectAll} variant="flat"
> onPress={selectAll}
全选 >
</Button> 全选
<Button </Button>
color="secondary" <Button
size="sm" color="secondary"
variant="flat" size="sm"
onPress={deselectAll} variant="flat"
> onPress={deselectAll}
清空 >
</Button> 清空
<Button </Button>
color="warning" <Button
isDisabled={selectedIds.size === 0} color="warning"
isLoading={batchUpgradeLoading} isDisabled={selectedIds.size === 0}
size="sm" isLoading={batchUpgradeLoading}
variant="flat" size="sm"
onPress={() => openUpgradeModal("batch")} variant="flat"
> onPress={() => openUpgradeModal("batch")}
升级 >
</Button> 升级
<Button </Button>
color="danger" <Button
isDisabled={selectedIds.size === 0} color="danger"
size="sm" isDisabled={selectedIds.size === 0}
variant="flat" size="sm"
onPress={() => setBatchDeleteModalOpen(true)} variant="flat"
> onPress={() => setBatchDeleteModalOpen(true)}
删除 >
</Button> 删除
<Button </Button>
color="secondary" <Button
size="sm" color="secondary"
variant="solid" size="sm"
onPress={toggleSelectMode} variant="solid"
> onPress={toggleSelectMode}
退出 >
</Button> 退出
</> </Button>
) : ( </>
<> ) : (
<Button <>
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45" <Button
color="default" className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
size="sm" color="default"
variant="flat" size="sm"
onPress={toggleSelectMode} variant="flat"
> onPress={toggleSelectMode}
批量 >
</Button> 批量
<Button </Button>
color="primary" <Button
size="sm" color="primary"
variant="flat" size="sm"
onPress={handleAdd} variant="flat"
> onPress={handleAdd}
新增 >
</Button> 新增
</> </Button>
)} </>
)}
</div>
</div> </div>
</div> </div>
+73 -71
View File
@@ -710,77 +710,79 @@ export default function TunnelPage() {
/> />
</div> </div>
<div className="flex min-h-9 min-w-0 max-w-full items-center justify-start gap-2 overflow-x-auto whitespace-nowrap touch-pan-x sm:justify-end [&>*]:shrink-0"> <div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
{selectMode ? ( <div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
<> {selectMode ? (
<span className="text-sm text-default-600 shrink-0"> <>
已选择 {selectedIds.size} 项 <span className="text-sm text-default-600 shrink-0">
</span> 已选择 {selectedIds.size} 项
<Button </span>
color="primary" <Button
size="sm" color="primary"
variant="flat" size="sm"
onPress={selectAll} variant="flat"
> onPress={selectAll}
全选 >
</Button> 全选
<Button </Button>
color="secondary" <Button
size="sm" color="secondary"
variant="flat" size="sm"
onPress={deselectAll} variant="flat"
> onPress={deselectAll}
清空 >
</Button> 清空
<Button </Button>
color="danger" <Button
isDisabled={selectedIds.size === 0} color="danger"
size="sm" isDisabled={selectedIds.size === 0}
variant="flat" size="sm"
onPress={() => setBatchDeleteModalOpen(true)} variant="flat"
> onPress={() => setBatchDeleteModalOpen(true)}
删除 >
</Button> 删除
<Button </Button>
color="primary" <Button
isDisabled={selectedIds.size === 0} color="primary"
isLoading={batchLoading} isDisabled={selectedIds.size === 0}
size="sm" isLoading={batchLoading}
variant="flat" size="sm"
onPress={handleBatchRedeploy} variant="flat"
> onPress={handleBatchRedeploy}
下发 >
</Button> 下发
<Button </Button>
color="secondary" <Button
size="sm" color="secondary"
variant="solid" size="sm"
onPress={toggleSelectMode} variant="solid"
> onPress={toggleSelectMode}
退出 >
</Button> 退出
</> </Button>
) : ( </>
<> ) : (
<Button <>
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45" <Button
color="default" className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
size="sm" color="default"
variant="flat" size="sm"
onPress={toggleSelectMode} variant="flat"
> onPress={toggleSelectMode}
批量 >
</Button> 批量
<Button </Button>
color="primary" <Button
size="sm" color="primary"
variant="flat" size="sm"
onPress={handleAdd} variant="flat"
> onPress={handleAdd}
新增 >
</Button> 新增
</> </Button>
)} </>
)}
</div>
</div> </div>
</div> </div>