diff --git a/plans/039-restore-removed-features-pr322.md b/plans/039-restore-removed-features-pr322.md new file mode 100644 index 0000000..0610cee --- /dev/null +++ b/plans/039-restore-removed-features-pr322.md @@ -0,0 +1,62 @@ +# 恢复 PR #322 移除的功能 + +**状态**: ✅ 已完成 + +## 背景 + +PR #322 (https://github.com/Sagit-chu/flvx/pull/322) 原本移除了三个功能,用户要求**加回**这些被移除的功能: +1. 批量操作失败详情弹窗(`BatchOperationFailure` 类型及相关处理) +2. 节点到期提醒关闭功能(`dismissNodeExpiryReminder` API) +3. 更新通道选择功能(稳定版/开发版切换) + +用户要求**保留**的改动: +- 版本显示简化(移除 "v" 前缀和更新可用徽章) + +## 任务清单 + +- [x] 检出 PR #322 到本地分支 `pr-322` +- [x] 恢复 `api/types.ts` 中的 `expiryReminderDismissed` 字段 +- [x] 恢复 `api/types.ts` 中的 `BatchOperationFailure` 类型和 `failures` 字段 +- [x] 恢复 `api/error-message.ts` 中的批量操作失败处理函数 +- [x] 恢复 `api/index.ts` 中的 `dismissNodeExpiryReminder` API +- [x] 恢复 `config.tsx` 中的更新通道选择功能 +- [x] 恢复 `use-dashboard-data.ts` 中的 `expiryReminderDismissed` 过滤逻辑 +- [x] 恢复 `batch-actions.ts` 中的 `BatchOperationFailure` 相关处理 +- [x] 恢复 `forward.tsx` 中的 `BatchActionResultModal` 使用 +- [x] 恢复 `tunnel.tsx` 中的 `BatchActionResultModal` 使用 +- [x] 提交并推送修改 + +## 修改的文件 + +- `vite-frontend/src/api/types.ts` - 添加 `expiryReminderDismissed` 和 `BatchOperationFailure` +- `vite-frontend/src/api/error-message.ts` - 添加批量操作失败处理函数 +- `vite-frontend/src/api/index.ts` - 添加 `dismissNodeExpiryReminder` API +- `vite-frontend/src/pages/config.tsx` - 添加更新通道选择功能 +- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` - 恢复 `expiryReminderDismissed` 过滤逻辑 +- `vite-frontend/src/pages/forward/batch-actions.ts` - 恢复批量操作失败处理 +- `vite-frontend/src/pages/forward.tsx` - 恢复 `BatchActionResultModal` 组件使用 +- `vite-frontend/src/pages/tunnel.tsx` - 恢复 `BatchActionResultModal` 组件使用 +- `vite-frontend/src/pages/node.tsx` - 恢复 `expiryReminderDismissed` 功能和 "关闭提醒" 按钮 + +## 保留的 UI 改进 + +- Modal 样式优化(group.tsx, limit.tsx, panel-sharing.tsx) +- 按钮文本简化 +- 用户页面隧道列表下拉展开 + +## forward.tsx 重构审查结果 + +PR #322 对 forward.tsx 进行了大规模重构(~2200 行 diff),经审查决定**保留**以下改动: + +| 改动 | 说明 | +|------|------| +| DnD 碰撞检测 | `closestCenter` → `pointerWithin`,更适合嵌套拖拽 | +| 高级筛选模态框 | 从 SearchBar 改为五合一筛选(名称/用户/隧道/端口/目标地址) | +| 始终显示复选框 | 移除 selectMode 状态,用户无需切换模式即可选择 | +| 组件位置移动 | Sortable 组件移到组件顶部,代码组织更好 | +| UI 改进 | 表头全选、端口独立列、倍率显示优化、Modal 样式、"落地地址"文案 | + +## 注意事项 + +- `version-footer.tsx` 保持简化版本显示(不恢复) +- `batch-action-result-modal.tsx` 组件文件未被 PR 删除,无需恢复(只需恢复 forward.tsx 中的使用) \ No newline at end of file diff --git a/vite-frontend/src/components/ui/checkbox.tsx b/vite-frontend/src/components/ui/checkbox.tsx index 118cbcd..3b2b76e 100644 --- a/vite-frontend/src/components/ui/checkbox.tsx +++ b/vite-frontend/src/components/ui/checkbox.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, MinusIcon } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -11,17 +11,18 @@ function Checkbox({ return ( - + + ); diff --git a/vite-frontend/src/components/ui/dialog.tsx b/vite-frontend/src/components/ui/dialog.tsx index 7e91038..a3f059f 100644 --- a/vite-frontend/src/components/ui/dialog.tsx +++ b/vite-frontend/src/components/ui/dialog.tsx @@ -56,18 +56,12 @@ function DialogContent({ { - e.preventDefault(); - }} - onCloseAutoFocus={(e) => { - e.preventDefault(); - }} + {...props} > {children} {showCloseButton && ( diff --git a/vite-frontend/src/layouts/admin.tsx b/vite-frontend/src/layouts/admin.tsx index 2639786..0ec185c 100644 --- a/vite-frontend/src/layouts/admin.tsx +++ b/vite-frontend/src/layouts/admin.tsx @@ -576,6 +576,9 @@ export default function AdminLayout({ {/* 修改密码弹窗 */} -
+

基本设置

配置网站的基本信息,这些设置会影响网站的显示效果

-
- -
@@ -975,19 +964,29 @@ export default function ConfigPage() {
+ +
+ +
{hasChanges && ( - - -
-
- - 检测到配置变更,请记得保存您的修改 - -
- + +
+
+ + 检测到配置变更,请记得保存您的修改 + +
)} @@ -1045,7 +1044,7 @@ export default function ConfigPage() { 公告支持 Markdown 语法,链接会在新标签页打开

-
+
+ {/* 倍率 */} + {tunnel.tunnelName} + + [{formatTunnelTrafficRatio(tunnel.tunnelTrafficRatio)}] + +
+
+ {tunnel.items.length} 条规则 +
+ +
+
+
+ {!collapsed &&
{children}
} +
+ ); +}; + +// 可拖拽的规则卡片组件 +const SortableForwardCard = ({ forward, renderCard }: any) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: forward.id }); + + const style: React.CSSProperties = { + transform: transform + ? CSS.Transform.toString({ + ...transform, + x: Math.round(transform.x), + y: Math.round(transform.y), + }) + : undefined, + transition: isDragging ? undefined : transition || undefined, + opacity: isDragging ? 0.5 : 1, + willChange: isDragging ? "transform" : undefined, + }; + + return ( +
+ {renderCard(forward, listeners)} +
+ ); +}; + +// 可拖拽的表格行组件 +const SortableTableRow = ({ + copyToClipboard, + forward, + selectedIds, + toggleSelect, + getStrategyDisplay, + formatInAddress, + formatRemoteAddress, + handleServiceToggle, + handleEdit, + handleDelete, + handleDiagnose, + showAddressModal, + hasMultipleAddresses, + formatFlow, +}: any) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: forward.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition: isDragging ? "none" : transition, + opacity: isDragging ? 0.6 : 1, + zIndex: isDragging ? 50 : undefined, + position: isDragging ? ("relative" as const) : undefined, + willChange: "transform", + backgroundColor: isDragging ? "var(--nextui-default-100)" : undefined, + }; + + const strategyDisplay = getStrategyDisplay(forward.strategy); + + return ( + + {true && ( + + toggleSelect(forward.id)} + /> + + )} + +
+ +
+
+ copyToClipboard(forward.name, "规则名")} + > + {forward.name} + + + + + + + + + + {strategyDisplay.text} + + + + + {formatFlow(getForwardDisplayFlow(forward))} + + + copyToClipboard(forward.inPort.toString(), "入口端口")} + > +
+ handleServiceToggle(forward)} + /> +
+
+ +
+ + + +
+
+
+ ); +}; + +const SortableCompactTableRow = ({ + copyToClipboard, + forward, + selectedIds, + toggleSelect, + getStrategyDisplay, + formatInAddress, + formatRemoteAddress, + handleServiceToggle, + handleEdit, + handleDelete, + handleDiagnose, + showAddressModal, + hasMultipleAddresses, + formatFlow, +}: any) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: forward.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition: isDragging ? "none" : transition, + opacity: isDragging ? 0.6 : 1, + zIndex: isDragging ? 50 : undefined, + position: isDragging ? ("relative" as const) : undefined, + willChange: "transform", + backgroundColor: isDragging ? "var(--nextui-default-100)" : undefined, + }; + + const strategyDisplay = getStrategyDisplay(forward.strategy); + + return ( + + {true && ( + + toggleSelect(forward.id)} + /> + + )} + +
+ +
+
+ + copyToClipboard(forward.name, "规则名")} + > + {forward.name} + + + +
+ + {forward.tunnelName} + + + ‾{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)} + +
+
+ + + + + + + + + + {strategyDisplay.text} + + + + + {formatFlow(getForwardDisplayFlow(forward))} + + + +
+ handleServiceToggle(forward)} + /> +
+
+ +
+ + + +
+
+
+ ); +}; + const getForwardDisplayFlow = (forward: Forward): number => { const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0); @@ -590,18 +1148,36 @@ const getForwardDisplayFlow = (forward: Forward): number => { }; export default function ForwardPage() { + const tokenUserId = JwtUtil.getUserIdFromToken(); + const tokenRoleId = JwtUtil.getRoleIdFromToken(); + const isAdmin = tokenRoleId === 0; + + const [searchParams, setSearchParams] = useState({ + name: "", + userId: tokenUserId ? tokenUserId.toString() : "all", + tunnelId: "all", + inPort: "", + remoteAddr: "", + }); + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); + const activeFilterCount = + (searchParams.name ? 1 : 0) + + (searchParams.userId !== "all" && + searchParams.userId !== (tokenUserId ? tokenUserId.toString() : "all") + ? 1 + : 0) + + (searchParams.tunnelId !== "all" ? 1 : 0) + + (searchParams.inPort ? 1 : 0) + + (searchParams.remoteAddr ? 1 : 0); const [loading, setLoading] = useState(true); const [forwards, setForwards] = useState([]); const [tunnels, setTunnels] = useState([]); const [allTunnels, setAllTunnels] = useState([]); const [nodes, setNodes] = useState([]); const [speedLimits, setSpeedLimits] = useState([]); - const isMobile = useMobileBreakpoint(); - const [searchKeyword, setSearchKeyword] = useLocalStorageState( - "forward-search-keyword", - "", - ); - const [isSearchVisible, setIsSearchVisible] = useState(false); + // const isMobile = useMobileBreakpoint(); + // searchKeyword removed + // isSearchVisible removed const [compactMode, setCompactMode] = useState(false); // 显示模式状态 - 从localStorage读取,默认为平铺显示 @@ -616,17 +1192,15 @@ export default function ForwardPage() { }); // 筛选状态 - const [filterUserId, setFilterUserId, resetFilterUserId] = - useLocalStorageState("forward-filter-user-id", "all"); - const [filterTunnelId, setFilterTunnelId, resetFilterTunnelId] = - useLocalStorageState("forward-filter-tunnel-id", "all"); + // filterUserId removed + // filterTunnelId removed // 拖拽排序相关状态 const [forwardOrder, setForwardOrder] = useState([]); // 模态框状态 const [modalOpen, setModalOpen] = useState(false); - const [isFilterModalOpen, setIsFilterModalOpen] = useState(false); + // isFilterModalOpen removed const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [addressModalOpen, setAddressModalOpen] = useState(false); const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false); @@ -701,21 +1275,18 @@ export default function ForwardPage() { const [batchTargetTunnelId, setBatchTargetTunnelId] = useState( null, ); - const [batchResultModal, setBatchResultModal] = - useState(EMPTY_BATCH_RESULT_MODAL_STATE); const [batchLoading, setBatchLoading] = useState(false); const [batchProgress, setBatchProgress] = useState({ active: false, label: "", percent: 0, }); + const [batchResultModal, setBatchResultModal] = + useState(EMPTY_BATCH_RESULT_MODAL_STATE); const [groupOrderMap, setGroupOrderMap] = useState({}); const [collapsedTunnelGroups, setCollapsedTunnelGroups] = useState({}); const [groupPreferenceHydrated, setGroupPreferenceHydrated] = useState(false); - const tokenUserId = JwtUtil.getUserIdFromToken(); - const tokenRoleId = JwtUtil.getRoleIdFromToken(); - const isAdmin = tokenRoleId === 0; const parseNodeIPs = (node?: Node): string[] => { if (!node) { @@ -1430,7 +2001,7 @@ export default function ForwardPage() { } if (!form.remoteAddr.trim()) { - newErrors.remoteAddr = "请输入远程地址"; + newErrors.remoteAddr = "请输入落地地址"; } else { // 验证地址格式 const addresses = form.remoteAddr @@ -1949,8 +2520,27 @@ export default function ForwardPage() { // 复制到剪贴板 const copyToClipboard = async (text: string, label: string = "内容") => { try { - await navigator.clipboard.writeText(text); - toast.success(`已复制${label}`); + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + toast.success(`已复制${label}`); + } else { + const textArea = document.createElement("textarea"); + + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-9999px"; + textArea.style.top = "0"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand("copy"); + toast.success(`已复制${label}`); + } catch (err) { + toast.error("复制失败"); + } + document.body.removeChild(textArea); + } } catch { toast.error("复制失败"); } @@ -2432,14 +3022,6 @@ export default function ForwardPage() { } } }; - - const toggleSelectMode = () => { - setSelectMode(!selectMode); - if (selectMode) { - setSelectedIds(new Set()); - } - }; - const toggleSelect = (id: number) => { const newSet = new Set(selectedIds); @@ -2450,13 +3032,6 @@ export default function ForwardPage() { } setSelectedIds(newSet); }; - - const selectAll = () => { - const allIds = sortedForwards.map((f) => f.id); - - setSelectedIds(new Set(allIds)); - }; - const deselectAll = () => { setSelectedIds(new Set()); }; @@ -2537,7 +3112,11 @@ export default function ForwardPage() { enable, ); - presentBatchOutcome(outcome); + if (outcome.toastVariant === "success") { + toast.success(outcome.toastMessage); + } else { + toast.error(outcome.toastMessage); + } if (outcome.shouldRefresh) { setBatchProgress({ @@ -2568,7 +3147,11 @@ export default function ForwardPage() { Array.from(selectedIds), ); - presentBatchOutcome(outcome); + if (outcome.toastVariant === "success") { + toast.success(outcome.toastMessage); + } else { + toast.error(outcome.toastMessage); + } if (outcome.shouldRefresh) { setBatchProgress({ @@ -2600,7 +3183,11 @@ export default function ForwardPage() { batchTargetTunnelId, ); - presentBatchOutcome(outcome); + if (outcome.toastVariant === "success") { + toast.success(outcome.toastMessage); + } else { + toast.error(outcome.toastMessage); + } if (outcome.shouldRefresh) { setBatchProgress({ @@ -2651,29 +3238,41 @@ export default function ForwardPage() { let filteredForwards = forwards; - if (filterUserId !== "all") { - const targetUserId = parseInt(filterUserId); + if (searchParams.userId !== "all") { + const targetUserId = parseInt(searchParams.userId); filteredForwards = filteredForwards.filter( (f) => f.userId === targetUserId || (targetUserId === 0 && !f.userId), ); } - if (filterTunnelId !== "all") { - const targetTunnelId = parseInt(filterTunnelId); + if (searchParams.tunnelId !== "all") { + const targetTunnelId = parseInt(searchParams.tunnelId); filteredForwards = filteredForwards.filter( (f) => f.tunnelId === targetTunnelId, ); } - - if (searchKeyword.trim()) { - const lowerKeyword = searchKeyword.toLowerCase(); + if (searchParams.name.trim()) { + const lowerName = searchParams.name.toLowerCase(); filteredForwards = filteredForwards.filter( - (f) => - (f.name && f.name.toLowerCase().includes(lowerKeyword)) || - (f.remoteAddr && f.remoteAddr.toLowerCase().includes(lowerKeyword)) || - (f.userName && f.userName.toLowerCase().includes(lowerKeyword)), + (f) => f.name && f.name.toLowerCase().includes(lowerName), + ); + } + if (searchParams.inPort.trim()) { + const targetPort = parseInt(searchParams.inPort.trim()); + + if (!isNaN(targetPort)) { + filteredForwards = filteredForwards.filter( + (f) => f.inPort === targetPort, + ); + } + } + if (searchParams.remoteAddr.trim()) { + const lowerAddr = searchParams.remoteAddr.toLowerCase(); + + filteredForwards = filteredForwards.filter( + (f) => f.remoteAddr && f.remoteAddr.toLowerCase().includes(lowerAddr), ); } @@ -2722,7 +3321,7 @@ export default function ForwardPage() { } return sortedByDb; - }, [forwards, forwardOrder, filterUserId, filterTunnelId, searchKeyword]); + }, [forwards, forwardOrder, searchParams]); const availableGroupData = useMemo( () => buildAvailableGroupData(forwards), @@ -2933,6 +3532,38 @@ export default function ForwardPage() { [sortedForwards], ); + const selectAll = () => { + const allIds = sortedForwards.map((f) => f.id); + + setSelectedIds(new Set(allIds)); + }; + + const isAllSelected = useMemo(() => { + return ( + sortedForwards && + sortedForwards.length > 0 && + selectedIds.size === sortedForwards.length + ); + }, [sortedForwards, selectedIds]); + + const isIndeterminate = useMemo(() => { + return ( + selectedIds.size > 0 && + sortedForwards && + selectedIds.size < sortedForwards.length + ); + }, [sortedForwards, selectedIds]); + + const handleSelectAllToggle = (isSelected: boolean) => { + if (isSelected) { + const allIds = sortedForwards.map((f) => f.id); + + setSelectedIds(new Set(allIds)); + } else { + setSelectedIds(new Set()); + } + }; + const toggleTunnelGroupCollapsed = (userId: number, tunnelKey: string) => { const collapseKey = buildTunnelGroupCollapseKey(userId, tunnelKey); const nextCollapsedMap: ForwardGroupCollapsedMap = { @@ -2950,146 +3581,6 @@ export default function ForwardPage() { void persistGroupCollapsedToGlobal(nextCollapsedMap); }; - const SortableTunnelGroupContainer = ({ - groupUserId, - tunnel, - collapsed, - onToggleCollapsed, - wrapperClassName, - headerClassName, - titleClassName, - countClassName, - bodyClassName, - children, - }: { - groupUserId: number; - tunnel: ForwardTunnelGroup; - collapsed: boolean; - onToggleCollapsed: () => void; - wrapperClassName: string; - headerClassName: string; - titleClassName: string; - countClassName: string; - bodyClassName: string; - children: React.ReactNode; - }) => { - const sortableId = buildTunnelGroupSortableId( - groupUserId, - tunnel.tunnelKey, - ); - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: sortableId }); - - const style: React.CSSProperties = { - transform: transform - ? CSS.Transform.toString({ - ...transform, - x: Math.round(transform.x), - y: Math.round(transform.y), - }) - : undefined, - transition: isDragging ? undefined : transition || undefined, - opacity: isDragging ? 0.55 : 1, - willChange: isDragging ? "transform" : undefined, - zIndex: isDragging ? 1 : undefined, - }; - - return ( -
-
-
- - {tunnel.tunnelName} - - {formatTunnelTrafficRatio(tunnel.tunnelTrafficRatio)} - -
-
- {tunnel.items.length} 条规则 -
- -
-
-
- {!collapsed &&
{children}
} -
- ); - }; - - // 可拖拽的规则卡片组件 - const SortableForwardCard = ({ forward }: { forward: Forward }) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: forward.id }); - - const style: React.CSSProperties = { - transform: transform - ? CSS.Transform.toString({ - ...transform, - x: Math.round(transform.x), - y: Math.round(transform.y), - }) - : undefined, - transition: isDragging ? undefined : transition || undefined, - opacity: isDragging ? 0.5 : 1, - willChange: isDragging ? "transform" : undefined, - }; - - return ( -
- {renderForwardCard(forward, listeners)} -
- ); - }; - // 生成用作筛选项的用户和隧道列表 const uniqueUsers = useMemo(() => { const userMap = new Map(); @@ -3137,432 +3628,6 @@ export default function ForwardPage() { return users; }, [forwards, isAdmin, tokenUserId]); - // 可拖拽的表格行组件 - const SortableTableRow = ({ - forward, - selectMode, - selectedIds, - toggleSelect, - getStrategyDisplay, - formatInAddress, - formatRemoteAddress, - handleServiceToggle, - handleEdit, - handleDelete, - handleDiagnose, - showAddressModal, - hasMultipleAddresses, - formatFlow, - }: any) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: forward.id }); - - const style = { - transform: transform - ? CSS.Transform.toString({ - ...transform, - x: Math.round(transform.x), - y: Math.round(transform.y), - }) - : undefined, - transition: isDragging ? undefined : transition || undefined, - opacity: isDragging ? 0.5 : 1, - backgroundColor: isDragging ? "var(--nextui-default-100)" : undefined, - }; - - const strategyDisplay = getStrategyDisplay(forward.strategy); - - return ( - - {selectMode && ( - - toggleSelect(forward.id)} - /> - - )} - -
- -
-
- - {forward.name} - - - - - - - - - - {strategyDisplay.text} - - - - - {formatFlow(getForwardDisplayFlow(forward))} - - - -
- handleServiceToggle(forward)} - /> -
-
- -
- - - -
-
-
- ); - }; - - const SortableCompactTableRow = ({ - forward, - selectMode, - selectedIds, - toggleSelect, - getStrategyDisplay, - formatInAddress, - formatRemoteAddress, - handleServiceToggle, - handleEdit, - handleDelete, - handleDiagnose, - showAddressModal, - hasMultipleAddresses, - formatFlow, - }: any) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: forward.id }); - - const style = { - transform: transform - ? CSS.Transform.toString({ - ...transform, - x: Math.round(transform.x), - y: Math.round(transform.y), - }) - : undefined, - transition: isDragging ? undefined : transition || undefined, - opacity: isDragging ? 0.5 : 1, - backgroundColor: isDragging ? "var(--nextui-default-100)" : undefined, - }; - - const strategyDisplay = getStrategyDisplay(forward.strategy); - - return ( - - {selectMode && ( - - toggleSelect(forward.id)} - /> - - )} - -
- -
-
- - - {forward.userName || "未知用户"} - - - - {forward.name} - - - - - {forward.tunnelName} - - - {formatTunnelTrafficRatio(forward.tunnelTrafficRatio)} - - - - - - - - - - - - {strategyDisplay.text} - - - - - {formatFlow(getForwardDisplayFlow(forward))} - - - -
- handleServiceToggle(forward)} - /> -
-
- -
- - - -
-
-
- ); - }; - // 渲染规则卡片 const renderForwardCard = (forward: Forward, listeners?: any) => { const statusDisplay = getStatusDisplay(forward.status); @@ -3575,7 +3640,7 @@ export default function ForwardPage() { >
- {selectMode && ( + {true && ( {forward.name} -

- {`${normalizeForwardTunnelName(forward.tunnelName)} · ${formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}`} -

+
+ {/* 倍率 */} + + {normalizeForwardTunnelName(forward.tunnelName)} + + + ‾{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)} + +
- {viewMode === "direct" && ( -
- -
- )} {statusDisplay.text} + {viewMode === "direct" && ( +
+ +
+ )}
@@ -3718,7 +3785,7 @@ export default function ForwardPage() { {/* 统计信息 */}
{/* 页面头部 */}
-
- setIsSearchVisible(false)} - onOpen={() => setIsSearchVisible(true)} - /> +
+ + {activeFilterCount > 0 && ( + + )}
- {selectMode ? ( + {selectedIds.size > 0 ? ( <> 已选择 {selectedIds.size} 项 @@ -3934,54 +4033,9 @@ export default function ForwardPage() { > 隧道 - ) : ( <> - {/* 筛选按钮 */} - {/* 显示模式切换按钮 */} - + + + )} + + + - - {/* 筛选模态框 */} - - - {(onClose) => ( - <> - - 筛选条件 - - -
-
-

按用户筛选

- -
-
-

按隧道筛选

- -
-
-
- - - - - - )} -
-
); } diff --git a/vite-frontend/src/pages/group.tsx b/vite-frontend/src/pages/group.tsx index 14e4945..2668b1e 100644 --- a/vite-frontend/src/pages/group.tsx +++ b/vite-frontend/src/pages/group.tsx @@ -477,10 +477,15 @@ export default function GroupPage() { )} - +

隧道分组

-
@@ -544,10 +549,15 @@ export default function GroupPage() {
- +

用户分组

-
@@ -651,7 +661,7 @@ export default function GroupPage() { size="sm" onPress={handleAssignPermission} > - 分配权限 + 分配
@@ -692,6 +702,10 @@ export default function GroupPage() { @@ -735,7 +749,14 @@ export default function GroupPage() { - + {editingUserGroup ? "编辑用户分组" : "新建用户分组"} @@ -777,6 +798,10 @@ export default function GroupPage() { @@ -824,6 +849,10 @@ export default function GroupPage() { diff --git a/vite-frontend/src/pages/limit.tsx b/vite-frontend/src/pages/limit.tsx index e47554d..a72314c 100644 --- a/vite-frontend/src/pages/limit.tsx +++ b/vite-frontend/src/pages/limit.tsx @@ -341,6 +341,9 @@ export default function LimitPage() { {/* 新增/编辑模态框 */} any; + children: (listeners: any, attributes?: any) => any; }) => { const { attributes, @@ -277,14 +275,8 @@ const SortableItem = ({ }; return ( -
- {children(listeners)} +
+ {children(listeners, attributes)}
); }; @@ -318,6 +310,8 @@ export default function NodePage() { const [submitLoading, setSubmitLoading] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); + const [rollbackModalOpen, setRollbackModalOpen] = useState(false); + const [nodeToRollback, setNodeToRollback] = useState(null); const [nodeToDelete, setNodeToDelete] = useState(null); const [protocolDisabled, setProtocolDisabled] = useState(false); const [protocolDisabledReason, setProtocolDisabledReason] = useState(""); @@ -330,7 +324,7 @@ export default function NodePage() { serverHost: "", serverIpV4: "", serverIpV6: "", - port: "1000-65535", + port: "10000-65535", tcpListenAddr: "[::]", udpListenAddr: "[::]", interfaceName: "", @@ -380,40 +374,6 @@ export default function NodePage() { const [upgradeProgress, setUpgradeProgress] = useState< Record >({}); - const [infoPopoverPlacement, setInfoPopoverPlacement] = useState< - Record - >({}); - - const updateInfoPopoverPlacement = useCallback( - (nodeId: number, triggerElement: HTMLElement | null) => { - if (!triggerElement) { - return; - } - - const rect = triggerElement.getBoundingClientRect(); - const cardElement = triggerElement.closest("[data-node-card='true']"); - const cardRect = - cardElement instanceof HTMLElement - ? cardElement.getBoundingClientRect() - : null; - const estimatedPanelWidth = 288; - const containerPadding = 16; - const availableLeftSpace = cardRect - ? rect.left - cardRect.left - : rect.left; - const nextPlacement: "left" | "bottom" = - availableLeftSpace >= estimatedPanelWidth + containerPadding - ? "left" - : "bottom"; - - setInfoPopoverPlacement((prev) => - prev[nodeId] === nextPlacement - ? prev - : { ...prev, [nodeId]: nextPlacement }, - ); - }, - [], - ); const handleNodeOffline = useCallback((nodeId: number) => { setNodeList((prev) => @@ -865,7 +825,7 @@ export default function NodePage() { serverHost: normalizedHost, serverIpV4: normalizedV4, serverIpV6: normalizedV6, - port: node.port || "1000-65535", + port: node.port || "10000-65535", tcpListenAddr: node.tcpListenAddr || "[::]", udpListenAddr: node.udpListenAddr || "[::]", interfaceName: (node as any).interfaceName || "", @@ -1096,7 +1056,16 @@ export default function NodePage() { }; // 回退节点 - const handleRollbackNode = async (node: Node) => { + const handleRollbackNode = (node: Node) => { + setNodeToRollback(node); + setRollbackModalOpen(true); + }; + + const confirmRollback = async () => { + if (!nodeToRollback) return; + const node = nodeToRollback; + + setRollbackModalOpen(false); setNodeList((prev) => prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)), ); @@ -1116,6 +1085,7 @@ export default function NodePage() { n.id === node.id ? { ...n, rollbackLoading: false } : n, ), ); + setNodeToRollback(null); } }; @@ -1199,7 +1169,7 @@ export default function NodePage() { serverHost: "", serverIpV4: "", serverIpV6: "", - port: "1000-65535", + port: "10000-65535", tcpListenAddr: "[::]", udpListenAddr: "[::]", interfaceName: "", @@ -1506,7 +1476,11 @@ export default function NodePage() { onPress={() => setActiveTab("local")} > 本地节点 - + {localNodes.length} @@ -1518,7 +1492,11 @@ export default function NodePage() { onPress={() => setActiveTab("remote")} > 远程节点 - + {remoteNodes.length} @@ -1540,7 +1518,17 @@ export default function NodePage() { value={currentSearchKeyword} onChange={setCurrentSearchKeyword} onClose={() => setIsSearchVisible(false)} - onOpen={() => setIsSearchVisible(true)} + onOpen={() => { + setIsSearchVisible(true); + setTimeout(() => { + // 精准抓取带有“搜索”字样的输入框并强制聚焦 + const searchInput = document.querySelector( + 'input[placeholder*="搜索"]', + ); + + if (searchInput) (searchInput as HTMLElement).focus(); + }, 150); // 150ms 刚好是 CSS 展开动画的时间 + }} />
@@ -1722,12 +1710,10 @@ export default function NodePage() { !node.expiryReminderDismissed, ); const hasInfoTrigger = hasRemark || hasExpiryInfo; - const infoCount = Number(hasExpiryInfo) + Number(hasRemark); - const infoPlacement = infoPopoverPlacement[node.id] ?? "left"; return ( - {(listeners) => ( + {(listeners, attributes) => (
-
- -
{selectMode && (
-
- {hasInfoTrigger && ( -
- -
-
- {hasExpiryInfo && ( -
-
-
- 到期提醒 -
- -
-
- - {expiryMeta.label} - -
-
- {formatNodeRenewalTime( - expiryMeta.nextDueTime, - )} -
-
- )} - - {hasRemark && ( -
-
- 备注 -
-
- {node.remark?.trim()} -
-
- )} -
-
-
- )} +
+
+ +
@@ -2030,7 +1917,7 @@ export default function NodePage() { {remoteUsage.usedPorts.map((port) => ( @@ -2227,6 +2114,60 @@ export default function NodePage() { )} + {hasInfoTrigger && ( +
+ {hasExpiryInfo && ( +
+
+
+ 到期提醒 +
+ +
+
+ + {expiryMeta.label} + +
+
+ {formatNodeRenewalTime( + expiryMeta.nextDueTime, + )} +
+
+ )} + + {hasRemark && ( +
+
+ 备注 +
+
+ {node.remark?.trim()} +
+
+ )} +
+ )} +
{/* 操作按钮 */}
@@ -2312,6 +2253,9 @@ export default function NodePage() { {/* 新增/编辑节点对话框 */}
- 0 - ? (parseDate( - new Date(form.expiryTime).toISOString().split("T")[0], - ) as any) - : null + ? new Date(form.expiryTime).toISOString().slice(0, 10) + : "" + } + variant="bordered" + onChange={(e) => + setForm((prev) => ({ + ...prev, + expiryTime: e.target.value + ? new Date(e.target.value).getTime() + : 0, + })) } - onChange={(date) => { - if (date) { - const jsDate = new Date( - date.year, - date.month - 1, - date.day, - ); - setForm((prev) => ({ - ...prev, - expiryTime: jsDate.getTime(), - })); - } else { - setForm((prev) => ({ - ...prev, - expiryTime: 0, - })); - } - }} /> @@ -2430,11 +2364,11 @@ export default function NodePage() {
@@ -2443,10 +2377,10 @@ export default function NodePage() { /> @@ -2717,7 +2651,7 @@ export default function NodePage() {
@@ -2737,9 +2671,53 @@ export default function NodePage() { + {/* 回退确认模态框 */} + + + {(onClose) => ( + <> + +

确认回退

+
+ +

+ 确定要将节点{" "} + "{nodeToRollback?.name}"{" "} + 回退到上一个版本吗? +

+

+ 节点将执行版本回退并自动重启,期间会导致节点短暂离线。 +

+
+ + + + + + )} +
+
+ {/* 删除确认模态框 */} setCreateShareOpen(false)} @@ -785,6 +789,10 @@ export default function PanelSharingPage() { {/* Edit Share Modal */} setEditShareOpen(false)} @@ -883,7 +891,14 @@ export default function PanelSharingPage() { {/* Import Node Modal */} - setImportNodeOpen(false)}> + setImportNodeOpen(false)} + > 导入远程节点 diff --git a/vite-frontend/src/pages/profile.tsx b/vite-frontend/src/pages/profile.tsx index 9cec7dd..86a2ada 100644 --- a/vite-frontend/src/pages/profile.tsx +++ b/vite-frontend/src/pages/profile.tsx @@ -327,6 +327,9 @@ export default function ProfilePage() { {/* 修改密码弹窗 */} >(new Set()); const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false); - const [batchResultModal, setBatchResultModal] = - useState(EMPTY_BATCH_RESULT_MODAL_STATE); const [batchLoading, setBatchLoading] = useState(false); const [batchProgress, setBatchProgress] = useState({ active: false, label: "", percent: 0, }); + const [batchResultModal, setBatchResultModal] = + useState(EMPTY_BATCH_RESULT_MODAL_STATE); useEffect(() => { return () => { @@ -1263,7 +1263,7 @@ export default function TunnelPage() {
@@ -3118,7 +3125,6 @@ export default function TunnelPage() { return; } - setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE); }} /> diff --git a/vite-frontend/src/pages/user.tsx b/vite-frontend/src/pages/user.tsx index e1fbc17..9a02c31 100644 --- a/vite-frontend/src/pages/user.tsx +++ b/vite-frontend/src/pages/user.tsx @@ -60,10 +60,10 @@ import { getUserGroups, } from "@/api"; import { - SearchIcon, EditIcon, DeleteIcon, SettingsIcon, + SearchIcon, } from "@/components/icons"; import { PageLoadingState } from "@/components/page-state"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; @@ -204,7 +204,7 @@ export default function UserPage() { user: "", pwd: "", status: 1, - flow: 100, + flow: 1000, dailyQuotaGB: 0, monthlyQuotaGB: 0, num: 10, @@ -234,6 +234,7 @@ export default function UserPage() { // 分配新隧道权限相关状态 const [assignLoading, setAssignLoading] = useState(false); + const [isTunnelListExpanded, setIsTunnelListExpanded] = useState(false); const [batchTunnelSelections, setBatchTunnelSelections] = useState< Map >(new Map()); @@ -469,7 +470,7 @@ export default function UserPage() { user: "", pwd: "", status: 1, - flow: 100, + flow: 1000, dailyQuotaGB: 0, monthlyQuotaGB: 0, num: 10, @@ -1049,26 +1050,6 @@ export default function UserPage() { {/* 其他信息 */}
- {(user.dailyQuotaGB ?? 0) > 0 || - (user.monthlyQuotaGB ?? 0) > 0 || - (user.disabledByQuota ?? 0) > 0 ? ( - <> -
- 每日配额 - - {formatFlow(Number(user.dailyUsedBytes ?? 0))} /{" "} - {formatQuotaLimit(user.dailyQuotaGB)} - -
-
- 每月配额 - - {formatFlow(Number(user.monthlyUsedBytes ?? 0))}{" "} - / {formatQuotaLimit(user.monthlyQuotaGB)} - -
- - ) : null}
规则数量 @@ -1180,6 +1161,9 @@ export default function UserPage() { {/* 用户表单模态框 */} ({ ...prev, flow: value })); }} /> - { - const value = Math.min( - Math.max(Number(e.target.value) || 0, 0), - 99999, - ); - - setUserForm((prev) => ({ ...prev, dailyQuotaGB: value })); - }} - /> - { - const value = Math.min( - Math.max(Number(e.target.value) || 0, 0), - 99999, - ); - - setUserForm((prev) => ({ ...prev, monthlyQuotaGB: value })); - }} - />

分配新权限

-
- 流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置 -
+
+

+ 隧道列表 +

-
- {tunnels.map((tunnel) => { - const isAssigned = isTunnelAssigned(tunnel.id); - const isSelected = batchTunnelSelections.has(tunnel.id); - const tunnelSpeedLimits = getSpeedLimitsForTunnel( - tunnel.id, - ); + {/* 顶部触发框 */} +
+ setIsTunnelListExpanded(!isTunnelListExpanded) + } + > + 0 ? "text-primary-500 font-bold" : "text-default-400"}`} + > + {batchTunnelSelections.size > 0 + ? `已选 ${batchTunnelSelections.size} 项:` + + Array.from(batchTunnelSelections.keys()) + .map( + (id) => tunnels.find((t) => t.id === id)?.name, + ) + .join("、") + : "请选择隧道(勾选后配置限速)"} + + + + +
- return ( -
- !isAssigned && toggleTunnelSelection(tunnel.id) - } - onKeyDown={(event) => { - if (isAssigned) { - return; - } + {/* 列表悬浮层 */} + {isTunnelListExpanded && ( +
e.stopPropagation()} + > +
+ {tunnels.map((tunnel) => { + const isAssigned = isTunnelAssigned(tunnel.id); + const isSelected = batchTunnelSelections.has( + tunnel.id, + ); + const tunnelSpeedLimits = getSpeedLimitsForTunnel( + tunnel.id, + ); + const currentSpeedId = batchTunnelSelections.get( + tunnel.id, + ); - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - toggleTunnelSelection(tunnel.id); - } - }} - > -
-
- event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - onValueChange={() => - toggleTunnelSelection(tunnel.id) - } - /> - { + if (isAssigned) return; + e.stopPropagation(); + toggleTunnelSelection(tunnel.id); + }} > - {tunnel.name} - - {isAssigned && ( - - 已分配 - - )} -
+
+ + + {tunnel.name} + +
- {isSelected && !isAssigned && ( -
- { + const selectedKey = Array.from(keys)[0]; - updateTunnelSpeedLimit( - tunnel.id, - selectedKey ? Number(selectedKey) : null, - ); - }} - > - {tunnelSpeedLimits.map((sl) => ( - - {sl.name} - - ))} - + {tunnelSpeedLimits.map((sl) => ( + + {sl.name} + + ))} + +
+ )} + {isAssigned && ( + + 已分配 + + )}
- )} -
+ ); + })} +
+
+
- ); - })} - {tunnels.length === 0 && ( -
- 暂无可用隧道
)}
-