From 375877b223ee94d08a1ff29046fe508a066e375a Mon Sep 17 00:00:00 2001 From: abai569ok Date: Sun, 15 Mar 2026 20:33:53 +0800 Subject: [PATCH 01/13] 2.1.8-beta1.7 --- vite-frontend/src/api/error-message.ts | 108 -- vite-frontend/src/api/index.ts | 2 - vite-frontend/src/api/types.ts | 9 - vite-frontend/src/components/ui/dialog.tsx | 8 +- .../src/components/version-footer.tsx | 9 +- vite-frontend/src/layouts/admin.tsx | 2 +- vite-frontend/src/pages/config.tsx | 89 +- .../src/pages/dashboard/use-dashboard-data.ts | 2 - vite-frontend/src/pages/forward.tsx | 1654 +++++++---------- .../src/pages/forward/batch-actions.ts | 52 +- vite-frontend/src/pages/group.tsx | 32 +- vite-frontend/src/pages/limit.tsx | 4 +- vite-frontend/src/pages/node.tsx | 473 ++--- vite-frontend/src/pages/panel-sharing.tsx | 6 +- vite-frontend/src/pages/profile.tsx | 2 +- vite-frontend/src/pages/tunnel.tsx | 120 +- vite-frontend/src/pages/user.tsx | 571 ++---- .../src/shadcn-bridge/heroui/modal.tsx | 8 +- 18 files changed, 1176 insertions(+), 1975 deletions(-) diff --git a/vite-frontend/src/api/error-message.ts b/vite-frontend/src/api/error-message.ts index 2e6e5f8..11d861f 100644 --- a/vite-frontend/src/api/error-message.ts +++ b/vite-frontend/src/api/error-message.ts @@ -1,5 +1,3 @@ -import type { BatchOperationFailure } from "@/api/types"; - import axios from "axios"; interface ErrorPayload { @@ -7,20 +5,6 @@ interface ErrorPayload { message?: string; } -interface BatchFailurePayload { - id?: number; - name?: string; - reason?: string; - msg?: string; - message?: string; -} - -interface BatchResultPayload { - failures?: unknown[]; -} - -const MAX_BATCH_FAILURES_IN_TOAST = 3; - export const isUnauthorizedError = (error: unknown): boolean => { return axios.isAxiosError(error) && error.response?.status === 401; }; @@ -41,95 +25,3 @@ export const extractApiErrorMessage = ( return fallback; }; - -const normalizeBatchFailure = ( - failure: unknown, -): BatchOperationFailure | null => { - if (typeof failure === "string") { - const reason = failure.trim(); - - return reason ? { reason } : null; - } - - const payload = (failure ?? {}) as BatchFailurePayload; - const id = - typeof payload.id === "number" && Number.isFinite(payload.id) - ? payload.id - : undefined; - const name = typeof payload.name === "string" ? payload.name.trim() : ""; - const reasonSource = [payload.reason, payload.msg, payload.message].find( - (item) => typeof item === "string" && item.trim() !== "", - ); - const reason = typeof reasonSource === "string" ? reasonSource.trim() : ""; - - if (!name && !reason && id === undefined) { - return null; - } - - return { - ...(id !== undefined ? { id } : {}), - ...(name ? { name } : {}), - ...(reason ? { reason } : {}), - }; -}; - -const normalizeBatchFailureReason = ( - failure: BatchOperationFailure, -): string => { - const name = typeof failure.name === "string" ? failure.name.trim() : ""; - const reason = - typeof failure.reason === "string" ? failure.reason.trim() : ""; - - if (name && reason) { - return `${name}: ${reason}`; - } - - if (reason) { - if (typeof failure.id === "number" && Number.isFinite(failure.id)) { - return `ID ${failure.id}: ${reason}`; - } - - return reason; - } - - if (name) { - return name; - } - - if (typeof failure.id === "number" && Number.isFinite(failure.id)) { - return `ID ${failure.id} 下发失败`; - } - - return ""; -}; - -export const extractBatchFailures = ( - result: unknown, -): BatchOperationFailure[] => { - const payload = (result ?? {}) as BatchResultPayload; - - return Array.isArray(payload.failures) - ? payload.failures - .map((item) => normalizeBatchFailure(item)) - .filter((item): item is BatchOperationFailure => item !== null) - : []; -}; - -export const buildBatchFailureMessage = ( - result: unknown, - fallbackSummary: string, -): string => { - const failures = extractBatchFailures(result) - .map((item) => normalizeBatchFailureReason(item)) - .filter((item) => item !== ""); - - if (failures.length === 0) { - return fallbackSummary; - } - - const visibleFailures = failures.slice(0, MAX_BATCH_FAILURES_IN_TOAST); - const hiddenCount = failures.length - visibleFailures.length; - const hiddenSuffix = hiddenCount > 0 ? ` 等 ${failures.length} 项` : ""; - - return `${fallbackSummary}:${visibleFailures.join(";")}${hiddenSuffix}`; -}; diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 3eeed4c..cf968ea 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -78,8 +78,6 @@ export const getNodeInstallCommand = ( export const updateNodeOrder = (data: { nodes: Array<{ id: number; inx: number }>; }) => Network.post("/node/update-order", data); -export const dismissNodeExpiryReminder = (id: number) => - Network.post("/node/dismiss-expiry-reminder", { id }); export const checkNodeStatus = (nodeId?: number) => { const params = nodeId ? { nodeId } : {}; diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index 2021ae0..f8d1043 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -6,7 +6,6 @@ export interface NodeApiItem { remark?: string; expiryTime?: number; renewalCycle?: "month" | "quarter" | "year" | ""; - expiryReminderDismissed?: number; syncError?: string; [key: string]: unknown; } @@ -211,14 +210,6 @@ export interface UserPackageInfoApiData { export interface BatchOperationResult { successCount: number; failCount: number; - failures?: BatchOperationFailure[]; - [key: string]: unknown; -} - -export interface BatchOperationFailure { - id?: number; - name?: string; - reason?: string; [key: string]: unknown; } 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/components/version-footer.tsx b/vite-frontend/src/components/version-footer.tsx index ed3abc7..29a2b57 100644 --- a/vite-frontend/src/components/version-footer.tsx +++ b/vite-frontend/src/components/version-footer.tsx @@ -87,12 +87,7 @@ export function VersionFooter({ return (

- v{version} - {updateAvailable && latestUpdateVersion && ( - - {latestUpdateVersion} - - )} + {version}

Powered by{" "} @@ -102,7 +97,7 @@ export function VersionFooter({ rel="noopener noreferrer" target="_blank" > - FLVX + Flvx

diff --git a/vite-frontend/src/layouts/admin.tsx b/vite-frontend/src/layouts/admin.tsx index 2639786..353d714 100644 --- a/vite-frontend/src/layouts/admin.tsx +++ b/vite-frontend/src/layouts/admin.tsx @@ -574,7 +574,7 @@ export default function AdminLayout({ {/* 修改密码弹窗 */} - { - setUpdateChannel(channel); - setUpdateReleaseChannel(channel); - toast.success( - `更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`, - ); - }; - const handleConfigChange = (key: string, value: string) => { const newConfigs = { ...configs, [key]: value }; @@ -882,24 +874,13 @@ export default function ConfigPage() { -
+

基本设置

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

-
- -
@@ -942,52 +923,28 @@ export default function ConfigPage() { -
-
-

- 更新通道 -

-

- 稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc - 的版本。 -

-
- - + {saving ? "保存中..." : "保存配置"} +
{hasChanges && ( - - -
-
- - 检测到配置变更,请记得保存您的修改 - -
- + +
+
+ + 检测到配置变更,请记得保存您的修改 + +
)} @@ -1045,7 +1002,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} + + +
{ e.stopPropagation(); copyToClipboard((forward.inIp || "").replace(/:\d+$/, "") || "默认IP", "入口地址"); }} + > + {formatInAddress(forward.inIp, forward.inPort)} +
+
+ +
{ e.stopPropagation(); copyToClipboard(forward.remoteAddr.split(",")[0].split(":")[0], "落地地址"); }} + > + {formatRemoteAddress(forward.remoteAddr)} +
+
+ + + {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)} +
+
+ copyToClipboard((forward.inIp || "").replace(/:\d+$/, "") || "默认IP", "入口地址")}>{(forward.inIp || "").replace(/:\d+$/, "") || "默认IP"} + copyToClipboard(forward.inPort.toString(), "入口端口")}>{forward.inPort} + {/* 入口端口点击复制 */} + copyToClipboard(forward.remoteAddr.split(',')[0].replace(/:\d+$/, ""), "落地地址")}>{forward.remoteAddr.split(',')[0].replace(/:\d+$/, "")}{forward.remoteAddr.includes(',') && ...} + copyToClipboard((forward.remoteAddr.split(',')[0].match(/:(\d+)$/)?.[1]) || "", "落地端口")}>{(forward.remoteAddr.split(',')[0].match(/:(\d+)$/)?.[1]) || "-"} + + {strategyDisplay.text} + {formatFlow(getForwardDisplayFlow(forward))} +
handleServiceToggle(forward)} />
+ +
+ + + +
+
+
+ ); +}; + + + const getForwardDisplayFlow = (forward: Forward): number => { const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0); @@ -590,18 +978,28 @@ 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 +1014,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,8 +1097,6 @@ 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, @@ -713,9 +1107,6 @@ export default function ForwardPage() { 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) { @@ -831,7 +1222,7 @@ export default function ForwardPage() { buildForwardGroupOrderLocalKey(tokenUserId), JSON.stringify(nextOrderMap), ); - } catch {} + } catch { } }; const persistGroupCollapsedToLocal = ( @@ -846,7 +1237,7 @@ export default function ForwardPage() { buildForwardGroupCollapsedLocalKey(tokenUserId), JSON.stringify(nextCollapsedMap), ); - } catch {} + } catch { } }; const persistGroupOrderToGlobal = async ( @@ -990,7 +1381,7 @@ export default function ForwardPage() { JSON.stringify(globalCollapsedBucket), ); } - } catch {} + } catch { } } if (cancelled) { @@ -1254,7 +1645,7 @@ export default function ForwardPage() { setViewMode(newMode); try { localStorage.setItem("forward-view-mode", newMode); - } catch {} + } catch { } }; const applyForwardList = useCallback( @@ -1430,7 +1821,7 @@ export default function ForwardPage() { } if (!form.remoteAddr.trim()) { - newErrors.remoteAddr = "请输入远程地址"; + newErrors.remoteAddr = "请输入落地地址"; } else { // 验证地址格式 const addresses = form.remoteAddr @@ -1644,10 +2035,10 @@ export default function ForwardPage() { if (res.code === 0) { const warningItems = Array.isArray((res as any).data?.warnings) ? (res as any).data.warnings - .map((item: unknown) => - typeof item === "string" ? item.trim() : "", - ) - .filter((item: string) => item) + .map((item: unknown) => + typeof item === "string" ? item.trim() : "", + ) + .filter((item: string) => item) : []; warningItems.forEach((warning: string) => { @@ -1760,7 +2151,7 @@ export default function ForwardPage() { onStart: (payload) => { const startForwardName = typeof payload.forwardName === "string" && - payload.forwardName.trim() !== "" + payload.forwardName.trim() !== "" ? payload.forwardName : forward.name; const startTotal = Number(payload.total); @@ -1949,11 +2340,22 @@ export default function ForwardPage() { // 复制到剪贴板 const copyToClipboard = async (text: string, label: string = "内容") => { try { - await navigator.clipboard.writeText(text); - toast.success(`已复制${label}`); - } catch { - toast.error("复制失败"); - } + 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 +2834,6 @@ export default function ForwardPage() { } } }; - - const toggleSelectMode = () => { - setSelectMode(!selectMode); - if (selectMode) { - setSelectedIds(new Set()); - } - }; - const toggleSelect = (id: number) => { const newSet = new Set(selectedIds); @@ -2450,47 +2844,10 @@ export default function ForwardPage() { } setSelectedIds(newSet); }; - - const selectAll = () => { - const allIds = sortedForwards.map((f) => f.id); - - setSelectedIds(new Set(allIds)); - }; - const deselectAll = () => { setSelectedIds(new Set()); }; - const presentBatchOutcome = useCallback( - (outcome: { - failureDetails?: BatchOperationFailure[]; - resultSummary?: string; - resultTitle?: string; - toastMessage: string; - toastVariant: "success" | "error"; - }) => { - const failureDetails = outcome.failureDetails || []; - - if (failureDetails.length > 0) { - setBatchResultModal({ - failures: failureDetails, - open: true, - summary: outcome.resultSummary || outcome.toastMessage, - title: outcome.resultTitle || "批量操作结果", - }); - - return; - } - - if (outcome.toastVariant === "success") { - toast.success(outcome.toastMessage); - } else { - toast.error(outcome.toastMessage); - } - }, - [], - ); - const handleBatchDelete = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); @@ -2502,7 +2859,11 @@ export default function ForwardPage() { try { const outcome = await executeForwardBatchDelete(Array.from(selectedIds)); - presentBatchOutcome(outcome); + if (outcome.toastVariant === "success") { + toast.success(outcome.toastMessage); + } else { + toast.error(outcome.toastMessage); + } if (outcome.shouldRefresh) { setBatchProgress({ @@ -2537,7 +2898,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 +2933,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 +2969,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,30 +3024,27 @@ export default function ForwardPage() { let filteredForwards = forwards; - if (filterUserId !== "all") { - const targetUserId = parseInt(filterUserId); - - filteredForwards = filteredForwards.filter( - (f) => f.userId === targetUserId || (targetUserId === 0 && !f.userId), - ); + 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); - - filteredForwards = filteredForwards.filter( - (f) => f.tunnelId === targetTunnelId, - ); + if (searchParams.tunnelId !== "all") { + const targetTunnelId = parseInt(searchParams.tunnelId); + filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId); } - - if (searchKeyword.trim()) { - const lowerKeyword = searchKeyword.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)), - ); + if (searchParams.name.trim()) { + const lowerName = searchParams.name.toLowerCase(); + filteredForwards = filteredForwards.filter(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 +3092,7 @@ export default function ForwardPage() { } return sortedByDb; - }, [forwards, forwardOrder, filterUserId, filterTunnelId, searchKeyword]); + }, [forwards, forwardOrder, searchParams]); const availableGroupData = useMemo( () => buildAvailableGroupData(forwards), @@ -2843,7 +3213,7 @@ export default function ForwardPage() { if ( normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === - 1 && + 1 && normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1 ) { existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio( @@ -2933,6 +3303,31 @@ 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 +3345,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 +3392,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 +3404,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" && (
)}
@@ -3635,11 +3449,10 @@ export default function ForwardPage() { {/* 地址信息 */}
+ {activeFilterCount > 0 && ( + + )}
- {selectMode ? ( + {selectedIds.size > 0 ? ( <> 已选择 {selectedIds.size} 项 @@ -3934,54 +3756,10 @@ export default function ForwardPage() { > 隧道 - + ) : ( <> - {/* 筛选按钮 */} - {/* 显示模式切换按钮 */} +
@@ -4246,7 +4020,7 @@ export default function ForwardPage() {
@@ -4265,10 +4039,10 @@ export default function ForwardPage() { .filter((id) => id > 0); const collapsed = sanitizedCollapsedTunnelGroups[ - buildTunnelGroupCollapseKey( - group.userId, - tunnel.tunnelKey, - ) + buildTunnelGroupCollapseKey( + group.userId, + tunnel.tunnelKey, + ) ] === true; return ( @@ -4290,7 +4064,7 @@ export default function ForwardPage() { } > @@ -4304,13 +4078,10 @@ export default function ForwardPage() { }} > - {selectMode && ( - - 选择 + {true && ( + + {/* @ts-ignore */} + )} - @@ -4481,10 +4252,10 @@ export default function ForwardPage() { .filter((id) => id > 0); const collapsed = sanitizedCollapsedTunnelGroups[ - buildTunnelGroupCollapseKey( - group.userId, - tunnel.tunnelKey, - ) + buildTunnelGroupCollapseKey( + group.userId, + tunnel.tunnelKey, + ) ] === true; return ( @@ -4506,10 +4277,10 @@ export default function ForwardPage() { } > {}} + onDragStart={() => { }} > @@ -4764,6 +4536,7 @@ export default function ForwardPage() { {/* 删除确认模态框 */} (
{result.success ? ( @@ -5167,11 +4942,10 @@ export default function ForwardPage() {
{result.success ? "成功" : "失败"} @@ -5183,11 +4957,10 @@ export default function ForwardPage() {
{result.message}
@@ -5219,12 +4992,7 @@ export default function ForwardPage() { {/* 诊断结果模态框 */}
{diagnosisProgress.completed > 0 || - diagnosisProgress.total > 0 + diagnosisProgress.total > 0 ? diagnosisProgress.success : diagnosisResult.results.filter((r) => r.success) - .length} + .length}
成功 @@ -5315,10 +5083,10 @@ export default function ForwardPage() {
{diagnosisProgress.completed > 0 || - diagnosisProgress.total > 0 + diagnosisProgress.total > 0 ? diagnosisProgress.failed : diagnosisResult.results.filter((r) => !r.success) - .length} + .length}
失败 @@ -5404,13 +5172,12 @@ export default function ForwardPage() { return (
@@ -5418,11 +5185,10 @@ export default function ForwardPage() { ) : ( {isSuccess ? "✓" : "✗"} @@ -5471,11 +5237,10 @@ export default function ForwardPage() { {isSuccess ? ( 0 + className={`font-semibold ${(result.packetLoss || 0) > 0 ? "text-warning" : "text-success" - }`} + }`} > {result.packetLoss?.toFixed(1)}% @@ -5591,24 +5356,22 @@ export default function ForwardPage() { return (
{isDiagnosing ? ( ) : ( {isSuccess ? "✓" : "✗"} @@ -5653,11 +5416,10 @@ export default function ForwardPage() {
0 + className={`text-lg font-bold ${(result.packetLoss || 0) > 0 ? "text-warning" : "text-success" - }`} + }`} > {result.packetLoss?.toFixed(1)}%
@@ -5686,11 +5448,10 @@ export default function ForwardPage() { ) : (
{isDiagnosing ? result.message || "诊断中..." @@ -5738,26 +5499,26 @@ export default function ForwardPage() { {diagnosisResult.results.some( (r) => r.success === false && !r.diagnosing, ) && ( -
-

- 失败详情 -

-
- {diagnosisResult.results - .filter((r) => r.success === false && !r.diagnosing) - .map((result, index) => ( - - ))} +
+

+ 失败详情 +

+
+ {diagnosisResult.results + .filter((r) => r.success === false && !r.diagnosing) + .map((result, index) => ( + + ))} +
-
- )} + )}
) : (
@@ -5804,6 +5565,7 @@ export default function ForwardPage() { {/* 批量删除确认模态框 */} @@ -5835,6 +5597,7 @@ export default function ForwardPage() { {/* 批量换隧道模态框 */} @@ -5883,48 +5646,40 @@ export default function ForwardPage() { - { - if (open) { - setBatchResultModal((prev) => ({ ...prev, open: true })); - return; - } - - setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE); - }} - /> - - {/* 筛选模态框 */} + {/* 搜索与筛选五合一模态框 */} {(onClose) => ( <> - 筛选条件 + 搜索筛选用户规则
-
-

按用户筛选

+ setSearchParams(prev => ({ ...prev, name: e.target.value }))} + variant="bordered" + /> + + {isAdmin && ( -
-
-

按隧道筛选

- -
+ + + setSearchParams(prev => ({ ...prev, inPort: e.target.value }))} + variant="bordered" + /> + + setSearchParams(prev => ({ ...prev, remoteAddr: e.target.value }))} + variant="bordered" + />
- )}
+ ); } diff --git a/vite-frontend/src/pages/forward/batch-actions.ts b/vite-frontend/src/pages/forward/batch-actions.ts index e375798..fc40bc0 100644 --- a/vite-frontend/src/pages/forward/batch-actions.ts +++ b/vite-frontend/src/pages/forward/batch-actions.ts @@ -1,4 +1,4 @@ -import type { BatchOperationFailure, BatchOperationResult } from "@/api/types"; +import type { BatchOperationResult } from "@/api/types"; import { batchChangeTunnel, @@ -7,19 +7,12 @@ import { batchRedeployForwards, batchResumeForwards, } from "@/api"; -import { - buildBatchFailureMessage, - extractBatchFailures, - extractApiErrorMessage, -} from "@/api/error-message"; +import { extractApiErrorMessage } from "@/api/error-message"; export interface ForwardBatchActionOutcome { toastVariant: "success" | "error"; toastMessage: string; shouldRefresh: boolean; - resultTitle?: string; - resultSummary?: string; - failureDetails?: BatchOperationFailure[]; progressPercent?: number; progressLabel?: string; closeDeleteModal?: boolean; @@ -33,41 +26,23 @@ const normalizeBatchResult = (value: unknown): BatchOperationResult => { return { successCount: Number(raw.successCount ?? 0), failCount: Number(raw.failCount ?? 0), - failures: extractBatchFailures(raw), }; }; const buildBatchToast = ( result: BatchOperationResult, successText: string, - resultTitle: string, -): Pick< - ForwardBatchActionOutcome, - | "toastVariant" - | "toastMessage" - | "resultTitle" - | "resultSummary" - | "failureDetails" -> => { +): Pick => { if (result.failCount === 0) { return { toastVariant: "success", toastMessage: successText, - resultTitle, - resultSummary: successText, - failureDetails: [], }; } return { toastVariant: "error", - toastMessage: buildBatchFailureMessage( - result, - `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - ), - resultTitle, - resultSummary: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - failureDetails: result.failures || [], + toastMessage: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, }; }; @@ -88,11 +63,7 @@ export const executeForwardBatchDelete = async ( const summary = normalizeBatchResult(response.data); return { - ...buildBatchToast( - summary, - `成功删除 ${summary.successCount} 项`, - "批量删除结果", - ), + ...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`), shouldRefresh: true, progressPercent: 100, progressLabel: `删除完成:成功 ${summary.successCount} 项`, @@ -134,7 +105,6 @@ export const executeForwardBatchToggleService = async ( enable ? `成功启用 ${summary.successCount} 项` : `成功停用 ${summary.successCount} 项`, - enable ? "批量启用结果" : "批量停用结果", ), shouldRefresh: true, progressPercent: 100, @@ -166,11 +136,7 @@ export const executeForwardBatchRedeploy = async ( const summary = normalizeBatchResult(response.data); return { - ...buildBatchToast( - summary, - `成功重新下发 ${summary.successCount} 项`, - "批量下发结果", - ), + ...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`), shouldRefresh: true, progressPercent: 100, progressLabel: `重新下发完成:成功 ${summary.successCount} 项`, @@ -205,11 +171,7 @@ export const executeForwardBatchChangeTunnel = async ( const summary = normalizeBatchResult(response.data); return { - ...buildBatchToast( - summary, - `成功换隧道 ${summary.successCount} 项`, - "批量换隧道结果", - ), + ...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`), shouldRefresh: true, progressPercent: 100, progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`, diff --git a/vite-frontend/src/pages/group.tsx b/vite-frontend/src/pages/group.tsx index 14e4945..580966a 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} > - 分配权限 + 分配
@@ -691,7 +701,7 @@ export default function GroupPage() { - @@ -735,7 +745,7 @@ export default function GroupPage() { - + {editingUserGroup ? "编辑用户分组" : "新建用户分组"} @@ -776,7 +786,7 @@ export default function GroupPage() { - @@ -823,7 +833,7 @@ export default function GroupPage() { - diff --git a/vite-frontend/src/pages/limit.tsx b/vite-frontend/src/pages/limit.tsx index e47554d..52caaaf 100644 --- a/vite-frontend/src/pages/limit.tsx +++ b/vite-frontend/src/pages/limit.tsx @@ -339,7 +339,7 @@ export default function LimitPage() { )} {/* 新增/编辑模态框 */} - {/* 删除确认模态框 */} - any; + children: (listeners: any, attributes?: any) => any; }) => { const { attributes, @@ -280,11 +277,9 @@ const SortableItem = ({
- {children(listeners)} + {children(listeners, attributes)}
); }; @@ -318,6 +313,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 +327,7 @@ export default function NodePage() { serverHost: "", serverIpV4: "", serverIpV6: "", - port: "1000-65535", + port: "10000-65535", tcpListenAddr: "[::]", udpListenAddr: "[::]", interfaceName: "", @@ -865,7 +862,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 || "", @@ -911,24 +908,6 @@ export default function NodePage() { } }; - const handleDismissExpiryReminder = async (nodeId: number) => { - try { - const res = await dismissNodeExpiryReminder(nodeId); - - if (res.code === 0) { - setNodeList((prev) => - prev.map((n) => - n.id === nodeId ? { ...n, expiryReminderDismissed: 1 } : n, - ), - ); - } else { - toast.error(res.msg || "操作失败"); - } - } catch { - toast.error("网络错误,请重试"); - } - }; - const openInstallSelector = (node: Node) => { setInstallTargetNode(node); setInstallChannel("stable"); @@ -1081,9 +1060,7 @@ export default function NodePage() { ); if (res.code === 0) { - toast.success( - `批量升级命令已发送到 ${selectedLocalIds.length} 个节点`, - ); + toast.success(`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`); } else { toast.error(res.msg || "批量升级失败"); } @@ -1096,13 +1073,20 @@ 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)), ); try { const res = await rollbackNode(node.id); - if (res.code === 0) { toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`); } else { @@ -1116,6 +1100,7 @@ export default function NodePage() { n.id === node.id ? { ...n, rollbackLoading: false } : n, ), ); + setNodeToRollback(null); } }; @@ -1199,7 +1184,7 @@ export default function NodePage() { serverHost: "", serverIpV4: "", serverIpV6: "", - port: "1000-65535", + port: "10000-65535", tcpListenAddr: "[::]", udpListenAddr: "[::]", interfaceName: "", @@ -1239,7 +1224,6 @@ export default function NodePage() { } const nextId = reorderedDisplayIds[reorderedDisplayIndex]; - reorderedDisplayIndex += 1; return nextId; @@ -1419,8 +1403,7 @@ export default function NodePage() { return nodes.filter( (node) => (node.name && node.name.toLowerCase().includes(normalizedKeyword)) || - (node.remark && - node.remark.toLowerCase().includes(normalizedKeyword)) || + (node.remark && node.remark.toLowerCase().includes(normalizedKeyword)) || (node.serverIp && node.serverIp.toLowerCase().includes(normalizedKeyword)) || (node.serverIpV4 && @@ -1440,31 +1423,34 @@ export default function NodePage() { [sortedNodes], ); - const filteredLocalNodes = useMemo(() => { - const keywordFiltered = filterNodesByKeyword( - localNodes, - localSearchKeyword, - ); + const filteredLocalNodes = useMemo( + () => { + const keywordFiltered = filterNodesByKeyword(localNodes, localSearchKeyword); - if (nodeFilterMode === "all") { - return keywordFiltered; - } - - return keywordFiltered.filter((node) => { - const expiryMeta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle); - - switch (nodeFilterMode) { - case "expiringSoon": - return expiryMeta.state === "expiringSoon"; - case "expired": - return expiryMeta.state === "expired"; - case "withExpiry": - return getNodeReminderEnabled(node); - default: - return true; + if (nodeFilterMode === "all") { + return keywordFiltered; } - }); - }, [filterNodesByKeyword, localNodes, localSearchKeyword, nodeFilterMode]); + + return keywordFiltered.filter((node) => { + const expiryMeta = getNodeExpiryMeta( + node.expiryTime, + node.renewalCycle, + ); + + switch (nodeFilterMode) { + case "expiringSoon": + return expiryMeta.state === "expiringSoon"; + case "expired": + return expiryMeta.state === "expired"; + case "withExpiry": + return getNodeReminderEnabled(node); + default: + return true; + } + }); + }, + [filterNodesByKeyword, localNodes, localSearchKeyword, nodeFilterMode], + ); const filteredRemoteNodes = useMemo( () => filterNodesByKeyword(remoteNodes, remoteSearchKeyword), @@ -1486,8 +1472,7 @@ export default function NodePage() { const canUseExpiryFilter = activeTab === "local"; const hasKeywordSearch = currentSearchKeyword.trim().length > 0; const hasActiveFilters = nodeFilterMode !== "all"; - const isDisplayFiltered = - hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters); + const isDisplayFiltered = hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters); const sortableNodeIds = useMemo( () => displayNodes.map((n) => n.id), @@ -1506,7 +1491,7 @@ export default function NodePage() { onPress={() => setActiveTab("local")} > 本地节点 - + {localNodes.length} @@ -1518,7 +1503,7 @@ export default function NodePage() { onPress={() => setActiveTab("remote")} > 远程节点 - + {remoteNodes.length} @@ -1540,7 +1525,14 @@ 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 展开动画的时间 + }} />
@@ -1600,6 +1592,7 @@ export default function NodePage() { -
-
- {hasExpiryInfo && ( -
-
-
- 到期提醒 -
- -
-
- - {expiryMeta.label} - -
-
- {formatNodeRenewalTime( - expiryMeta.nextDueTime, - )} -
-
- )} - - {hasRemark && ( -
-
- 备注 -
-
- {node.remark?.trim()} -
-
- )} -
-
-
- )} -
+
+ +
+
@@ -1963,47 +1836,29 @@ export default function NodePage() { <>
- - 远程地址 - + 远程地址 - {remoteUsage.remoteUrl || - node.remoteUrl || - "-"} + {remoteUsage.remoteUrl || node.remoteUrl || "-"}
- - 共享ID - - - #{remoteUsage.shareId} - + 共享ID + #{remoteUsage.shareId}
- - 流量 - + 流量 {formatFlow(remoteUsage.currentFlow)}
- - 带宽上限 - + 带宽上限 {remoteUsage.maxBandwidth > 0 - ? formatSpeed( - remoteUsage.maxBandwidth, - ) + ? formatSpeed(remoteUsage.maxBandwidth) : "不限"}
@@ -2011,12 +1866,9 @@ export default function NodePage() {
- - 占用端口 - + 占用端口 - {remoteUsage.usedPorts.length}/ - {Math.max( + {remoteUsage.usedPorts.length}/{Math.max( remoteUsage.portRangeEnd - remoteUsage.portRangeStart + 1, @@ -2030,7 +1882,7 @@ export default function NodePage() { {remoteUsage.usedPorts.map((port) => ( @@ -2039,18 +1891,14 @@ export default function NodePage() { ))}
) : ( -
- 暂无占用端口 -
+
暂无占用端口
)}
- - 绑定明细 - + 绑定明细 {remoteUsage.activeBindingNum} @@ -2074,22 +1922,13 @@ export default function NodePage() {
- - {formatChainType( - binding.chainType, - binding.hopInx, - )} - - - 端口 {binding.allocatedPort} - + {formatChainType(binding.chainType, binding.hopInx)} + 端口 {binding.allocatedPort}
)) ) : ( -
- 暂无绑定明细 -
+
暂无绑定明细
)}
@@ -2227,6 +2066,38 @@ export default function NodePage() { )} + + {hasInfoTrigger && ( +
+ {hasExpiryInfo && ( +
+ + 提醒: + + + {expiryMeta.label} + + + {formatNodeRenewalTime(expiryMeta.nextDueTime).split(' ')[0]} + +
+ )} + + {hasRemark && ( +
+ + 备注:{node.remark?.trim()} + +
+ )} +
+ )} +
{/* 操作按钮 */}
@@ -2310,7 +2181,7 @@ 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 +2290,11 @@ export default function NodePage() {
@@ -2443,10 +2303,10 @@ export default function NodePage() { /> @@ -2717,7 +2577,7 @@ export default function NodePage() {
@@ -2737,8 +2597,45 @@ export default function NodePage() { - {/* 删除确认模态框 */} + {/* 回退确认模态框 */} + + {(onClose) => ( + <> + +

确认回退

+
+ +

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

+

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

+
+ + + + + + )} +
+
+ + {/* 删除确认模态框 */} + - {/* 安装命令模态框 */} - {/* 版本选择升级模态框 */} - {/* 批量删除确认模态框 */} - - {/* Create Share Modal */} - setCreateShareOpen(false)} @@ -784,7 +784,7 @@ export default function PanelSharingPage() { {/* Edit Share Modal */} - setEditShareOpen(false)} @@ -883,7 +883,7 @@ 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..f6bb899 100644 --- a/vite-frontend/src/pages/profile.tsx +++ b/vite-frontend/src/pages/profile.tsx @@ -325,7 +325,7 @@ export default function ProfilePage() {
{/* 修改密码弹窗 */} - { @@ -247,8 +226,6 @@ export default function TunnelPage() { const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(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, @@ -381,7 +358,7 @@ export default function TunnelPage() { }); setErrors({}); setModalOpen(true); - }; + }; // 删除隧道 const handleDelete = (tunnel: Tunnel) => { @@ -865,18 +842,6 @@ export default function TunnelPage() { setSelectedIds(new Set()); }; - const openBatchResultModal = useCallback( - (title: string, summary: string, failures: BatchOperationFailure[]) => { - setBatchResultModal({ - failures, - open: true, - summary, - title, - }); - }, - [], - ); - const handleBatchDelete = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); @@ -909,19 +874,9 @@ export default function TunnelPage() { return next; }); } else { - const failures = extractBatchFailures(result); - - if (failures.length > 0) { - openBatchResultModal( - "批量删除结果", - `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - failures, - ); - } else { - toast.error( - `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - ); - } + toast.error( + `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, + ); setBatchProgress({ active: true, label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`, @@ -960,22 +915,9 @@ export default function TunnelPage() { if (result.failCount === 0) { toast.success(`成功重新下发 ${result.successCount} 项`); } else { - const failures = extractBatchFailures(result); - - if (failures.length > 0) { - openBatchResultModal( - "批量下发结果", - `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - failures, - ); - } else { - toast.error( - buildBatchFailureMessage( - result, - `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - ), - ); - } + toast.error( + `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, + ); } setSelectedIds(new Set()); setSelectMode(false); @@ -1262,21 +1204,7 @@ export default function TunnelPage() {
-
- -
+
@@ -1415,6 +1343,7 @@ export default function TunnelPage() {
)}
+
@@ -1513,7 +1442,7 @@ export default function TunnelPage() { )} {/* 新增/编辑模态框 */} - {/* 删除确认模态框 */} - - @@ -3106,22 +3030,6 @@ export default function TunnelPage() { )} - - { - if (open) { - setBatchResultModal((prev) => ({ ...prev, open: true })); - - 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..f4ed6b2 100644 --- a/vite-frontend/src/pages/user.tsx +++ b/vite-frontend/src/pages/user.tsx @@ -1,3 +1,4 @@ +import { SearchBar } from "@/components/search-bar"; import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import toast from "react-hot-toast"; import { parseDate } from "@internationalized/date"; @@ -204,7 +205,7 @@ export default function UserPage() { user: "", pwd: "", status: 1, - flow: 100, + flow: 1000, dailyQuotaGB: 0, monthlyQuotaGB: 0, num: 10, @@ -234,6 +235,7 @@ export default function UserPage() { // 分配新隧道权限相关状态 const [assignLoading, setAssignLoading] = useState(false); + const [isTunnelListExpanded, setIsTunnelListExpanded] = useState(false); const [batchTunnelSelections, setBatchTunnelSelections] = useState< Map >(new Map()); @@ -469,7 +471,7 @@ export default function UserPage() { user: "", pwd: "", status: 1, - flow: 100, + flow: 1000, dailyQuotaGB: 0, monthlyQuotaGB: 0, num: 10, @@ -782,7 +784,6 @@ export default function UserPage() { const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => { const userId = userForm.id; - if (!userId) { return; } @@ -887,67 +888,34 @@ export default function UserPage() { {/* 页面头部 */}
-
- {!isSearchVisible ? ( - - ) : ( -
- setSearchKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - /> - -
- )} +
+ { + setIsSearchVisible(false); + setSearchKeyword(""); + }} + onOpen={() => { + setIsSearchVisible(true); + setTimeout(() => { + const searchInput = document.querySelector('input[placeholder*="搜索"]'); + if (searchInput) (searchInput as HTMLElement).focus(); + }, 150); + }} + />
- - +
{/* 用户列表 */} @@ -1049,26 +1017,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}
规则数量 @@ -1178,7 +1126,7 @@ 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 })); - }} - />
每月用量
- {formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))}{" "} - / {formatQuotaLimit(editingUser.monthlyQuotaGB)} + {formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))} /{" "} + {formatQuotaLimit(editingUser.monthlyQuotaGB)}
@@ -1452,7 +1368,7 @@ export default function UserPage() {

分配新权限

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

隧道列表

+ + {/* 顶部触发框 */} +
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('、') + : "请选择隧道(勾选后配置限速)"} + + +
-
- {tunnels.map((tunnel) => { - const isAssigned = isTunnelAssigned(tunnel.id); - const isSelected = batchTunnelSelections.has(tunnel.id); - const tunnelSpeedLimits = getSpeedLimitsForTunnel( - tunnel.id, - ); + {/* 列表悬浮层 */} + {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); - return ( -
- !isAssigned && toggleTunnelSelection(tunnel.id) - } - onKeyDown={(event) => { - if (isAssigned) { - return; - } - - 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); + }} > - {sl.name} - - ))} - + {tunnelSpeedLimits.map(sl => {sl.name})} + +
+ )} + {isAssigned && 已分配}
- )} -
+ ); + })} +
+
+
- ); - })} - {tunnels.length === 0 && ( -
- 暂无可用隧道
)}
- -
+
- - - + + + {/* 编辑隧道权限模态框 */} - {/* 删除确认对话框 */} - {/* 删除隧道权限确认对话框 */} - {/* 重置流量确认对话框 */} - {/* 重置隧道流量确认对话框 */} - { - e.preventDefault(); - }} - onCloseAutoFocus={(e) => { - e.preventDefault(); - }} + {...props} > {renderedChildren} From 7d07fe08b7062967d8bbb22703ebacc459e1571c Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sun, 15 Mar 2026 20:53:25 +0800 Subject: [PATCH 02/13] Restore removed features from PR #322 - Add back BatchOperationFailure type and batch failure handling functions - Add back dismissNodeExpiryReminder API endpoint - Add back update channel selection UI in config page - Keep simplified version display in version-footer.tsx --- plans/039-restore-removed-features-pr322.md | 36 ++++++ vite-frontend/src/api/error-message.ts | 108 ++++++++++++++++++ vite-frontend/src/api/index.ts | 2 + vite-frontend/src/api/types.ts | 9 ++ vite-frontend/src/pages/config.tsx | 60 +++++++++- .../src/pages/dashboard/use-dashboard-data.ts | 2 + .../src/pages/forward/batch-actions.ts | 54 +++++++-- 7 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 plans/039-restore-removed-features-pr322.md diff --git a/plans/039-restore-removed-features-pr322.md b/plans/039-restore-removed-features-pr322.md new file mode 100644 index 0000000..ff336f1 --- /dev/null +++ b/plans/039-restore-removed-features-pr322.md @@ -0,0 +1,36 @@ +# 恢复 PR #322 移除的功能 + +**目标**: 修改 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] 运行 lint 验证 +- [ ] 提交并推送修改 + +## 修改的文件 + +- `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` - 添加更新通道选择功能 + +## 注意事项 + +- `version-footer.tsx` 保持简化版本显示(不恢复) +- `batch-action-result-modal.tsx` 未被 PR 修改,无需恢复 \ No newline at end of file diff --git a/vite-frontend/src/api/error-message.ts b/vite-frontend/src/api/error-message.ts index 11d861f..2e6e5f8 100644 --- a/vite-frontend/src/api/error-message.ts +++ b/vite-frontend/src/api/error-message.ts @@ -1,3 +1,5 @@ +import type { BatchOperationFailure } from "@/api/types"; + import axios from "axios"; interface ErrorPayload { @@ -5,6 +7,20 @@ interface ErrorPayload { message?: string; } +interface BatchFailurePayload { + id?: number; + name?: string; + reason?: string; + msg?: string; + message?: string; +} + +interface BatchResultPayload { + failures?: unknown[]; +} + +const MAX_BATCH_FAILURES_IN_TOAST = 3; + export const isUnauthorizedError = (error: unknown): boolean => { return axios.isAxiosError(error) && error.response?.status === 401; }; @@ -25,3 +41,95 @@ export const extractApiErrorMessage = ( return fallback; }; + +const normalizeBatchFailure = ( + failure: unknown, +): BatchOperationFailure | null => { + if (typeof failure === "string") { + const reason = failure.trim(); + + return reason ? { reason } : null; + } + + const payload = (failure ?? {}) as BatchFailurePayload; + const id = + typeof payload.id === "number" && Number.isFinite(payload.id) + ? payload.id + : undefined; + const name = typeof payload.name === "string" ? payload.name.trim() : ""; + const reasonSource = [payload.reason, payload.msg, payload.message].find( + (item) => typeof item === "string" && item.trim() !== "", + ); + const reason = typeof reasonSource === "string" ? reasonSource.trim() : ""; + + if (!name && !reason && id === undefined) { + return null; + } + + return { + ...(id !== undefined ? { id } : {}), + ...(name ? { name } : {}), + ...(reason ? { reason } : {}), + }; +}; + +const normalizeBatchFailureReason = ( + failure: BatchOperationFailure, +): string => { + const name = typeof failure.name === "string" ? failure.name.trim() : ""; + const reason = + typeof failure.reason === "string" ? failure.reason.trim() : ""; + + if (name && reason) { + return `${name}: ${reason}`; + } + + if (reason) { + if (typeof failure.id === "number" && Number.isFinite(failure.id)) { + return `ID ${failure.id}: ${reason}`; + } + + return reason; + } + + if (name) { + return name; + } + + if (typeof failure.id === "number" && Number.isFinite(failure.id)) { + return `ID ${failure.id} 下发失败`; + } + + return ""; +}; + +export const extractBatchFailures = ( + result: unknown, +): BatchOperationFailure[] => { + const payload = (result ?? {}) as BatchResultPayload; + + return Array.isArray(payload.failures) + ? payload.failures + .map((item) => normalizeBatchFailure(item)) + .filter((item): item is BatchOperationFailure => item !== null) + : []; +}; + +export const buildBatchFailureMessage = ( + result: unknown, + fallbackSummary: string, +): string => { + const failures = extractBatchFailures(result) + .map((item) => normalizeBatchFailureReason(item)) + .filter((item) => item !== ""); + + if (failures.length === 0) { + return fallbackSummary; + } + + const visibleFailures = failures.slice(0, MAX_BATCH_FAILURES_IN_TOAST); + const hiddenCount = failures.length - visibleFailures.length; + const hiddenSuffix = hiddenCount > 0 ? ` 等 ${failures.length} 项` : ""; + + return `${fallbackSummary}:${visibleFailures.join(";")}${hiddenSuffix}`; +}; diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index cf968ea..3eeed4c 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -78,6 +78,8 @@ export const getNodeInstallCommand = ( export const updateNodeOrder = (data: { nodes: Array<{ id: number; inx: number }>; }) => Network.post("/node/update-order", data); +export const dismissNodeExpiryReminder = (id: number) => + Network.post("/node/dismiss-expiry-reminder", { id }); export const checkNodeStatus = (nodeId?: number) => { const params = nodeId ? { nodeId } : {}; diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index f8d1043..2021ae0 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -6,6 +6,7 @@ export interface NodeApiItem { remark?: string; expiryTime?: number; renewalCycle?: "month" | "quarter" | "year" | ""; + expiryReminderDismissed?: number; syncError?: string; [key: string]: unknown; } @@ -210,6 +211,14 @@ export interface UserPackageInfoApiData { export interface BatchOperationResult { successCount: number; failCount: number; + failures?: BatchOperationFailure[]; + [key: string]: unknown; +} + +export interface BatchOperationFailure { + id?: number; + name?: string; + reason?: string; [key: string]: unknown; } diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx index 617e27b..815c229 100644 --- a/vite-frontend/src/pages/config.tsx +++ b/vite-frontend/src/pages/config.tsx @@ -332,6 +332,14 @@ export default function ConfigPage() { } }; + const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => { + setUpdateChannel(channel); + setUpdateReleaseChannel(channel); + toast.success( + `更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`, + ); + }; + const handleConfigChange = (key: string, value: string) => { const newConfigs = { ...configs, [key]: value }; @@ -923,6 +931,40 @@ export default function ConfigPage() { +
+
+

+ 更新通道 +

+

+ 稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc + 的版本。 +

+
+ + +
+
copyToClipboard(forward.name, "规则名")}>{forward.name} + className={`${selectedIds.has(forward.id) ? "bg-primary-50/70 dark:bg-primary-900/40" : ""} ${FORWARD_GROUPED_TABLE_COLUMN_CLASS.name} whitespace-nowrap text-foreground cursor-pointer hover:text-primary transition-colors`} + onClick={() => copyToClipboard(forward.name, "规则名")} + > + {forward.name}
{ e.stopPropagation(); copyToClipboard((forward.inIp || "").replace(/:\d+$/, "") || "默认IP", "入口地址"); }} + }`} + title="点击复制" + onClick={(e) => { + e.stopPropagation(); + copyToClipboard( + (forward.inIp || "").replace(/:\d+$/, "") || "默认IP", + "入口地址", + ); + }} > {formatInAddress(forward.inIp, forward.inPort)}
@@ -781,11 +806,19 @@ const SortableTableRow = ({ className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.target} max-w-[280px] ${selectedIds.has(forward.id) ? "bg-primary-50/70 dark:bg-primary-900/40" : ""}`} >
{ e.stopPropagation(); copyToClipboard(forward.remoteAddr.split(",")[0].split(":")[0], "落地地址"); }} + }`} + title="点击复制" + onClick={(e) => { + e.stopPropagation(); + copyToClipboard( + forward.remoteAddr.split(",")[0].split(":")[0], + "落地地址", + ); + }} > {formatRemoteAddress(forward.remoteAddr)}
@@ -807,7 +840,10 @@ const SortableTableRow = ({ {formatFlow(getForwardDisplayFlow(forward))}
- copyToClipboard(forward.inPort.toString(), "入口端口")}> + copyToClipboard(forward.inPort.toString(), "入口端口")} + >
{true && ( - - toggleSelect(forward.id)} /> + + toggleSelect(forward.id)} + /> )} - -
- + +
+
- copyToClipboard(forward.name, "规则名")}>{forward.name} - + + copyToClipboard(forward.name, "规则名")} + > + {forward.name} + + +
- {forward.tunnelName} - ‾{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)} + + {forward.tunnelName} + + + ‾{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)} +
- copyToClipboard((forward.inIp || "").replace(/:\d+$/, "") || "默认IP", "入口地址")}>{(forward.inIp || "").replace(/:\d+$/, "") || "默认IP"} - copyToClipboard(forward.inPort.toString(), "入口端口")}>{forward.inPort} + + + copyToClipboard( + (forward.inIp || "").replace(/:\d+$/, "") || "默认IP", + "入口地址", + ) + } + > + {(forward.inIp || "").replace(/:\d+$/, "") || "默认IP"} + + + + copyToClipboard(forward.inPort.toString(), "入口端口")} + > + {forward.inPort} + + {/* 入口端口点击复制 */} - copyToClipboard(forward.remoteAddr.split(',')[0].replace(/:\d+$/, ""), "落地地址")}>{forward.remoteAddr.split(',')[0].replace(/:\d+$/, "")}{forward.remoteAddr.includes(',') && ...} - copyToClipboard((forward.remoteAddr.split(',')[0].match(/:(\d+)$/)?.[1]) || "", "落地端口")}>{(forward.remoteAddr.split(',')[0].match(/:(\d+)$/)?.[1]) || "-"} - - {strategyDisplay.text} - {formatFlow(getForwardDisplayFlow(forward))} -
handleServiceToggle(forward)} />
- + + + copyToClipboard( + forward.remoteAddr.split(",")[0].replace(/:\d+$/, ""), + "落地地址", + ) + } + > + {forward.remoteAddr.split(",")[0].replace(/:\d+$/, "")} + + {forward.remoteAddr.includes(",") && ( + ... + )} + + + + copyToClipboard( + forward.remoteAddr.split(",")[0].match(/:(\d+)$/)?.[1] || "", + "落地端口", + ) + } + > + {forward.remoteAddr.split(",")[0].match(/:(\d+)$/)?.[1] || "-"} + + + + + + {strategyDisplay.text} + + + + + {formatFlow(getForwardDisplayFlow(forward))} + + + +
+ handleServiceToggle(forward)} + /> +
+
+
- - - + + +
); }; - - const getForwardDisplayFlow = (forward: Forward): number => { const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0); @@ -987,10 +1190,18 @@ export default function ForwardPage() { userId: tokenUserId ? tokenUserId.toString() : "all", tunnelId: "all", inPort: "", - remoteAddr: "" + 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 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([]); @@ -1103,6 +1314,8 @@ export default function ForwardPage() { label: "", percent: 0, }); + const [batchResultModal, setBatchResultModal] = + useState(EMPTY_BATCH_RESULT_MODAL_STATE); const [groupOrderMap, setGroupOrderMap] = useState({}); const [collapsedTunnelGroups, setCollapsedTunnelGroups] = useState({}); @@ -1222,7 +1435,7 @@ export default function ForwardPage() { buildForwardGroupOrderLocalKey(tokenUserId), JSON.stringify(nextOrderMap), ); - } catch { } + } catch {} }; const persistGroupCollapsedToLocal = ( @@ -1237,7 +1450,7 @@ export default function ForwardPage() { buildForwardGroupCollapsedLocalKey(tokenUserId), JSON.stringify(nextCollapsedMap), ); - } catch { } + } catch {} }; const persistGroupOrderToGlobal = async ( @@ -1381,7 +1594,7 @@ export default function ForwardPage() { JSON.stringify(globalCollapsedBucket), ); } - } catch { } + } catch {} } if (cancelled) { @@ -1645,7 +1858,7 @@ export default function ForwardPage() { setViewMode(newMode); try { localStorage.setItem("forward-view-mode", newMode); - } catch { } + } catch {} }; const applyForwardList = useCallback( @@ -2035,10 +2248,10 @@ export default function ForwardPage() { if (res.code === 0) { const warningItems = Array.isArray((res as any).data?.warnings) ? (res as any).data.warnings - .map((item: unknown) => - typeof item === "string" ? item.trim() : "", - ) - .filter((item: string) => item) + .map((item: unknown) => + typeof item === "string" ? item.trim() : "", + ) + .filter((item: string) => item) : []; warningItems.forEach((warning: string) => { @@ -2151,7 +2364,7 @@ export default function ForwardPage() { onStart: (payload) => { const startForwardName = typeof payload.forwardName === "string" && - payload.forwardName.trim() !== "" + payload.forwardName.trim() !== "" ? payload.forwardName : forward.name; const startTotal = Number(payload.total); @@ -2345,17 +2558,25 @@ export default function ForwardPage() { 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("复制失败"); } + textArea.focus(); + textArea.select(); + try { + document.execCommand("copy"); + toast.success(`已复制${label}`); + } catch (err) { + toast.error("复制失败"); + } document.body.removeChild(textArea); } - } catch { toast.error("复制失败"); } + } catch { + toast.error("复制失败"); + } }; // 复制地址 @@ -2848,6 +3069,36 @@ export default function ForwardPage() { setSelectedIds(new Set()); }; + const presentBatchOutcome = useCallback( + (outcome: { + failureDetails?: BatchOperationFailure[]; + resultSummary?: string; + resultTitle?: string; + toastMessage: string; + toastVariant: "success" | "error"; + }) => { + const failureDetails = outcome.failureDetails || []; + + if (failureDetails.length > 0) { + setBatchResultModal({ + failures: failureDetails, + open: true, + summary: outcome.resultSummary || outcome.toastMessage, + title: outcome.resultTitle || "批量操作结果", + }); + + return; + } + + if (outcome.toastVariant === "success") { + toast.success(outcome.toastMessage); + } else { + toast.error(outcome.toastMessage); + } + }, + [], + ); + const handleBatchDelete = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); @@ -2859,11 +3110,7 @@ export default function ForwardPage() { try { const outcome = await executeForwardBatchDelete(Array.from(selectedIds)); - if (outcome.toastVariant === "success") { - toast.success(outcome.toastMessage); - } else { - toast.error(outcome.toastMessage); - } + presentBatchOutcome(outcome); if (outcome.shouldRefresh) { setBatchProgress({ @@ -3026,25 +3273,40 @@ export default function ForwardPage() { if (searchParams.userId !== "all") { const targetUserId = parseInt(searchParams.userId); - filteredForwards = filteredForwards.filter(f => f.userId === targetUserId || (targetUserId === 0 && !f.userId)); + + filteredForwards = filteredForwards.filter( + (f) => f.userId === targetUserId || (targetUserId === 0 && !f.userId), + ); } if (searchParams.tunnelId !== "all") { const targetTunnelId = parseInt(searchParams.tunnelId); - filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId); + + filteredForwards = filteredForwards.filter( + (f) => f.tunnelId === targetTunnelId, + ); } if (searchParams.name.trim()) { const lowerName = searchParams.name.toLowerCase(); - filteredForwards = filteredForwards.filter(f => f.name && f.name.toLowerCase().includes(lowerName)); + + filteredForwards = filteredForwards.filter( + (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); + 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)); + + filteredForwards = filteredForwards.filter( + (f) => f.remoteAddr && f.remoteAddr.toLowerCase().includes(lowerAddr), + ); } // 确保过滤后的规则列表有效 @@ -3213,7 +3475,7 @@ export default function ForwardPage() { if ( normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === - 1 && + 1 && normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1 ) { existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio( @@ -3303,31 +3565,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; + return ( + sortedForwards && + sortedForwards.length > 0 && + selectedIds.size === sortedForwards.length + ); }, [sortedForwards, selectedIds]); const isIndeterminate = useMemo(() => { - return selectedIds.size > 0 && sortedForwards && selectedIds.size < sortedForwards.length; + 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 = { @@ -3417,14 +3686,15 @@ export default function ForwardPage() {
{/* 倍率 */} - {normalizeForwardTunnelName(forward.tunnelName)} + + {normalizeForwardTunnelName(forward.tunnelName)} + ‾{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}
- {statusDisplay.text} - {viewMode === "direct" && (
)} + {viewMode === "direct" && ( +
+ +
+ )}
@@ -3449,10 +3735,11 @@ export default function ForwardPage() { {/* 地址信息 */}
{activeFilterCount > 0 && ( - )} @@ -3756,7 +4066,6 @@ export default function ForwardPage() { > 隧道 - ) : ( <> @@ -3821,8 +4130,6 @@ export default function ForwardPage() { 导出 - -
@@ -4039,10 +4369,10 @@ export default function ForwardPage() { .filter((id) => id > 0); const collapsed = sanitizedCollapsedTunnelGroups[ - buildTunnelGroupCollapseKey( - group.userId, - tunnel.tunnelKey, - ) + buildTunnelGroupCollapseKey( + group.userId, + tunnel.tunnelKey, + ) ] === true; return ( @@ -4081,7 +4411,12 @@ export default function ForwardPage() { {true && ( {/* @ts-ignore */} - + )} - id > 0); const collapsed = sanitizedCollapsedTunnelGroups[ - buildTunnelGroupCollapseKey( - group.userId, - tunnel.tunnelKey, - ) + buildTunnelGroupCollapseKey( + group.userId, + tunnel.tunnelKey, + ) ] === true; return ( @@ -4280,7 +4616,7 @@ export default function ForwardPage() { collisionDetection={pointerWithin} sensors={sensors} onDragEnd={handleDragEnd} - onDragStart={() => { }} + onDragStart={() => {}} > (
{result.success ? ( @@ -4942,10 +5289,11 @@ export default function ForwardPage() {
{result.success ? "成功" : "失败"} @@ -4957,10 +5305,11 @@ export default function ForwardPage() {
{result.message}
@@ -4992,7 +5341,9 @@ export default function ForwardPage() { {/* 诊断结果模态框 */}
{diagnosisProgress.completed > 0 || - diagnosisProgress.total > 0 + diagnosisProgress.total > 0 ? diagnosisProgress.success : diagnosisResult.results.filter((r) => r.success) - .length} + .length}
成功 @@ -5083,10 +5434,10 @@ export default function ForwardPage() {
{diagnosisProgress.completed > 0 || - diagnosisProgress.total > 0 + diagnosisProgress.total > 0 ? diagnosisProgress.failed : diagnosisResult.results.filter((r) => !r.success) - .length} + .length}
失败 @@ -5172,12 +5523,13 @@ export default function ForwardPage() { return (
@@ -5185,10 +5537,11 @@ export default function ForwardPage() { ) : ( {isSuccess ? "✓" : "✗"} @@ -5237,10 +5590,11 @@ export default function ForwardPage() { {isSuccess ? ( 0 + className={`font-semibold ${ + (result.packetLoss || 0) > 0 ? "text-warning" : "text-success" - }`} + }`} > {result.packetLoss?.toFixed(1)}% @@ -5356,22 +5710,24 @@ export default function ForwardPage() { return (
{isDiagnosing ? ( ) : ( {isSuccess ? "✓" : "✗"} @@ -5416,10 +5772,11 @@ export default function ForwardPage() {
0 + className={`text-lg font-bold ${ + (result.packetLoss || 0) > 0 ? "text-warning" : "text-success" - }`} + }`} > {result.packetLoss?.toFixed(1)}%
@@ -5448,10 +5805,11 @@ export default function ForwardPage() { ) : (
{isDiagnosing ? result.message || "诊断中..." @@ -5499,26 +5857,26 @@ export default function ForwardPage() { {diagnosisResult.results.some( (r) => r.success === false && !r.diagnosing, ) && ( -
-

- 失败详情 -

-
- {diagnosisResult.results - .filter((r) => r.success === false && !r.diagnosing) - .map((result, index) => ( - - ))} -
+
+

+ 失败详情 +

+
+ {diagnosisResult.results + .filter((r) => r.success === false && !r.diagnosing) + .map((result, index) => ( + + ))}
- )} +
+ )}
) : (
@@ -5565,7 +5923,9 @@ export default function ForwardPage() { {/* 批量删除确认模态框 */} @@ -5597,7 +5957,9 @@ export default function ForwardPage() { {/* 批量换隧道模态框 */} @@ -5646,10 +6008,11 @@ export default function ForwardPage() { - {/* 搜索与筛选五合一模态框 */} setSearchParams(prev => ({ ...prev, name: e.target.value }))} variant="bordered" + onChange={(e) => + setSearchParams((prev) => ({ + ...prev, + name: e.target.value, + })) + } /> {isAdmin && ( @@ -5679,7 +6047,11 @@ export default function ForwardPage() { variant="bordered" onSelectionChange={(keys) => { const key = Array.from(keys)[0] as string; - setSearchParams(prev => ({ ...prev, userId: key || "all" })); + + setSearchParams((prev) => ({ + ...prev, + userId: key || "all", + })); }} > 全部用户 @@ -5698,7 +6070,11 @@ export default function ForwardPage() { variant="bordered" onSelectionChange={(keys) => { const key = Array.from(keys)[0] as string; - setSearchParams(prev => ({ ...prev, tunnelId: key || "all" })); + + setSearchParams((prev) => ({ + ...prev, + tunnelId: key || "all", + })); }} > 全部隧道 @@ -5714,16 +6090,26 @@ export default function ForwardPage() { placeholder="请输入具体端口号" type="number" value={searchParams.inPort} - onChange={(e) => setSearchParams(prev => ({ ...prev, inPort: e.target.value }))} variant="bordered" + onChange={(e) => + setSearchParams((prev) => ({ + ...prev, + inPort: e.target.value, + })) + } /> setSearchParams(prev => ({ ...prev, remoteAddr: e.target.value }))} variant="bordered" + onChange={(e) => + setSearchParams((prev) => ({ + ...prev, + remoteAddr: e.target.value, + })) + } />
@@ -5737,7 +6123,7 @@ export default function ForwardPage() { userId: tokenUserId ? tokenUserId.toString() : "all", tunnelId: "all", inPort: "", - remoteAddr: "" + remoteAddr: "", }); }} > @@ -5749,6 +6135,21 @@ export default function ForwardPage() { + { + if (open) { + setBatchResultModal((prev) => ({ ...prev, open: true })); + + return; + } + + setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE); + }} + /> ); } diff --git a/vite-frontend/src/pages/forward/batch-actions.ts b/vite-frontend/src/pages/forward/batch-actions.ts index 8647846..e375798 100644 --- a/vite-frontend/src/pages/forward/batch-actions.ts +++ b/vite-frontend/src/pages/forward/batch-actions.ts @@ -223,4 +223,4 @@ export const executeForwardBatchChangeTunnel = async ( shouldRefresh: false, }; } -}; \ No newline at end of file +}; From f2ee9390065a2016e7f684e3cdc8cc1492476cd8 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sun, 15 Mar 2026 22:12:33 +0800 Subject: [PATCH 07/13] Restore BatchActionResultModal usage in tunnel.tsx --- plans/039-restore-removed-features-pr322.md | 4 +- vite-frontend/src/pages/tunnel.tsx | 125 +++++++++++++++++--- 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/plans/039-restore-removed-features-pr322.md b/plans/039-restore-removed-features-pr322.md index ebd1ee2..6a56669 100644 --- a/plans/039-restore-removed-features-pr322.md +++ b/plans/039-restore-removed-features-pr322.md @@ -23,7 +23,8 @@ PR #322 (https://github.com/Sagit-chu/flvx/pull/322) 原本移除了三个功能 - [x] 恢复 `use-dashboard-data.ts` 中的 `expiryReminderDismissed` 过滤逻辑 - [x] 恢复 `batch-actions.ts` 中的 `BatchOperationFailure` 相关处理 - [x] 恢复 `forward.tsx` 中的 `BatchActionResultModal` 使用 -- [x] 提交并推送修改 +- [x] 恢复 `tunnel.tsx` 中的 `BatchActionResultModal` 使用 +- [ ] 提交并推送修改 ## 修改的文件 @@ -34,6 +35,7 @@ PR #322 (https://github.com/Sagit-chu/flvx/pull/322) 原本移除了三个功能 - `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` 组件使用 ## 注意事项 diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index bbf43fb..9d24970 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -1,3 +1,5 @@ +import type { BatchOperationFailure } from "@/api/types"; + import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import toast from "react-hot-toast"; import { @@ -20,6 +22,7 @@ import { CSS } from "@dnd-kit/utilities"; import { SearchBar } from "@/components/search-bar"; import { AnimatedPage } from "@/components/animated-page"; +import { BatchActionResultModal } from "@/components/batch-action-result-modal"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Button } from "@/shadcn-bridge/heroui/button"; import { Input, Textarea } from "@/shadcn-bridge/heroui/input"; @@ -63,7 +66,11 @@ import { } from "@/pages/tunnel/form"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { loadStoredOrder, saveOrder } from "@/utils/order-storage"; -import { extractApiErrorMessage } from "@/api/error-message"; +import { + buildBatchFailureMessage, + extractBatchFailures, + extractApiErrorMessage, +} from "@/api/error-message"; interface ChainTunnel { nodeId: number; @@ -122,6 +129,20 @@ interface BatchProgressState { percent: number; } +interface BatchResultModalState { + failures: BatchOperationFailure[]; + open: boolean; + summary: string; + title: string; +} + +const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = { + failures: [], + open: false, + summary: "", + title: "", +}; + const TUNNEL_ORDER_KEY = "tunnel-order"; const mapTunnelApiItems = (items: any[]): Tunnel[] => { @@ -232,6 +253,8 @@ export default function TunnelPage() { label: "", percent: 0, }); + const [batchResultModal, setBatchResultModal] = + useState(EMPTY_BATCH_RESULT_MODAL_STATE); useEffect(() => { return () => { @@ -358,7 +381,7 @@ export default function TunnelPage() { }); setErrors({}); setModalOpen(true); - }; + }; // 删除隧道 const handleDelete = (tunnel: Tunnel) => { @@ -842,6 +865,18 @@ export default function TunnelPage() { setSelectedIds(new Set()); }; + const openBatchResultModal = useCallback( + (title: string, summary: string, failures: BatchOperationFailure[]) => { + setBatchResultModal({ + failures, + open: true, + summary, + title, + }); + }, + [], + ); + const handleBatchDelete = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); @@ -874,9 +909,19 @@ export default function TunnelPage() { return next; }); } else { - toast.error( - `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - ); + const failures = extractBatchFailures(result); + + if (failures.length > 0) { + openBatchResultModal( + "批量删除结果", + `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, + failures, + ); + } else { + toast.error( + `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, + ); + } setBatchProgress({ active: true, label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`, @@ -915,9 +960,22 @@ export default function TunnelPage() { if (result.failCount === 0) { toast.success(`成功重新下发 ${result.successCount} 项`); } else { - toast.error( - `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - ); + const failures = extractBatchFailures(result); + + if (failures.length > 0) { + openBatchResultModal( + "批量下发结果", + `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, + failures, + ); + } else { + toast.error( + buildBatchFailureMessage( + result, + `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, + ), + ); + } } setSelectedIds(new Set()); setSelectMode(false); @@ -1204,7 +1262,21 @@ export default function TunnelPage() {
-
+
+ +
@@ -1343,7 +1415,6 @@ export default function TunnelPage() {
)}
-
@@ -1442,8 +1513,11 @@ export default function TunnelPage() { )} {/* 新增/编辑模态框 */} - {/* 删除确认模态框 */} - - @@ -3030,6 +3113,20 @@ export default function TunnelPage() { )} + + { + if (open) { + setBatchResultModal((prev) => ({ ...prev, open: true })); + return; + } + setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE); + }} + /> ); } From 5f78dd66fc3d39b16319427247c29d3742541cfe Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sun, 15 Mar 2026 22:12:51 +0800 Subject: [PATCH 08/13] Update plan: all restoration tasks completed --- plans/039-restore-removed-features-pr322.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/039-restore-removed-features-pr322.md b/plans/039-restore-removed-features-pr322.md index 6a56669..93504cd 100644 --- a/plans/039-restore-removed-features-pr322.md +++ b/plans/039-restore-removed-features-pr322.md @@ -24,7 +24,7 @@ PR #322 (https://github.com/Sagit-chu/flvx/pull/322) 原本移除了三个功能 - [x] 恢复 `batch-actions.ts` 中的 `BatchOperationFailure` 相关处理 - [x] 恢复 `forward.tsx` 中的 `BatchActionResultModal` 使用 - [x] 恢复 `tunnel.tsx` 中的 `BatchActionResultModal` 使用 -- [ ] 提交并推送修改 +- [x] 提交并推送修改 ## 修改的文件 From 8f336377f6a188b3b4738895f6a9de1dca934c6f Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sun, 15 Mar 2026 22:28:44 +0800 Subject: [PATCH 09/13] Revert user page search bar to inline implementation for consistency --- vite-frontend/src/pages/user.tsx | 424 ++++++++++++++++++++----------- 1 file changed, 270 insertions(+), 154 deletions(-) diff --git a/vite-frontend/src/pages/user.tsx b/vite-frontend/src/pages/user.tsx index f4ed6b2..2a3a557 100644 --- a/vite-frontend/src/pages/user.tsx +++ b/vite-frontend/src/pages/user.tsx @@ -1,4 +1,3 @@ -import { SearchBar } from "@/components/search-bar"; import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import toast from "react-hot-toast"; import { parseDate } from "@internationalized/date"; @@ -60,12 +59,7 @@ import { getUserGroupList, getUserGroups, } from "@/api"; -import { - SearchIcon, - EditIcon, - DeleteIcon, - SettingsIcon, -} from "@/components/icons"; +import { EditIcon, DeleteIcon, SettingsIcon, SearchIcon } from "@/components/icons"; import { PageLoadingState } from "@/components/page-state"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { removeItemsById, replaceItemById } from "@/utils/list-state"; @@ -784,6 +778,7 @@ export default function UserPage() { const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => { const userId = userForm.id; + if (!userId) { return; } @@ -888,34 +883,67 @@ export default function UserPage() { {/* 页面头部 */}
-
- { - setIsSearchVisible(false); - setSearchKeyword(""); - }} - onOpen={() => { - setIsSearchVisible(true); - setTimeout(() => { - const searchInput = document.querySelector('input[placeholder*="搜索"]'); - if (searchInput) (searchInput as HTMLElement).focus(); - }, 150); - }} - /> +
+ {!isSearchVisible ? ( + + ) : ( +
+ setSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + /> + +
+ )}
- + +
{/* 用户列表 */} @@ -1126,8 +1154,11 @@ export default function UserPage() { )} {/* 用户表单模态框 */} -
每月用量
- {formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))} /{" "} - {formatQuotaLimit(editingUser.monthlyQuotaGB)} + {formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))}{" "} + / {formatQuotaLimit(editingUser.monthlyQuotaGB)}
@@ -1386,37 +1417,61 @@ export default function UserPage() {

分配新权限

-

隧道列表

- +

+ 隧道列表 +

+ {/* 顶部触发框 */} -
setIsTunnelListExpanded(!isTunnelListExpanded)} + onClick={() => + 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('、') + 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("、") : "请选择隧道(勾选后配置限速)"} - + + +
{/* 列表悬浮层 */} {isTunnelListExpanded && ( -
e.stopPropagation()} + onClick={(e) => 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); + const isSelected = batchTunnelSelections.has( + tunnel.id, + ); + const tunnelSpeedLimits = getSpeedLimitsForTunnel( + tunnel.id, + ); + const currentSpeedId = batchTunnelSelections.get( + tunnel.id, + ); return ( -
{ @@ -1426,47 +1481,80 @@ export default function UserPage() { }} >
- - {tunnel.name} + + {tunnel.name} +
{/* 右侧限速选择:复刻 image_24879b */} {isSelected && !isAssigned && ( -
e.stopPropagation()}> +
e.stopPropagation()} + >
)} - {isAssigned && 已分配} + {isAssigned && ( + + 已分配 + + )}
); })}
- +
)}
-
+
+ + +
+ + + )} + +
- - - + + + {/* 编辑隧道权限模态框 */} - {/* 删除确认对话框 */} - {/* 删除隧道权限确认对话框 */} - {/* 重置流量确认对话框 */} - {/* 重置隧道流量确认对话框 */} - Date: Sun, 15 Mar 2026 22:51:09 +0800 Subject: [PATCH 10/13] Keep UI improvements: Modal styles, expiryReminderDismissed, BatchActionResultModal --- vite-frontend/src/layouts/admin.tsx | 5 +- vite-frontend/src/pages/group.tsx | 43 ++- vite-frontend/src/pages/limit.tsx | 10 +- vite-frontend/src/pages/node.tsx | 332 +++++++++++++++------- vite-frontend/src/pages/panel-sharing.tsx | 21 +- vite-frontend/src/pages/profile.tsx | 5 +- vite-frontend/src/pages/tunnel.tsx | 1 + vite-frontend/src/pages/user.tsx | 7 +- 8 files changed, 304 insertions(+), 120 deletions(-) diff --git a/vite-frontend/src/layouts/admin.tsx b/vite-frontend/src/layouts/admin.tsx index 353d714..0ec185c 100644 --- a/vite-frontend/src/layouts/admin.tsx +++ b/vite-frontend/src/layouts/admin.tsx @@ -574,8 +574,11 @@ export default function AdminLayout({
{/* 修改密码弹窗 */} -

隧道分组

- @@ -1503,7 +1526,11 @@ export default function NodePage() { onPress={() => setActiveTab("remote")} > 远程节点 - + {remoteNodes.length} @@ -1526,13 +1553,16 @@ export default function NodePage() { onChange={setCurrentSearchKeyword} onClose={() => setIsSearchVisible(false)} onOpen={() => { - setIsSearchVisible(true); - setTimeout(() => { - // 精准抓取带有“搜索”字样的输入框并强制聚焦 - const searchInput = document.querySelector('input[placeholder*="搜索"]'); - if (searchInput) (searchInput as HTMLElement).focus(); - }, 150); // 150ms 刚好是 CSS 展开动画的时间 - }} + setIsSearchVisible(true); + setTimeout(() => { + // 精准抓取带有“搜索”字样的输入框并强制聚焦 + const searchInput = document.querySelector( + 'input[placeholder*="搜索"]', + ); + + if (searchInput) (searchInput as HTMLElement).focus(); + }, 150); // 150ms 刚好是 CSS 展开动画的时间 + }} />
@@ -1592,7 +1622,6 @@ export default function NodePage() {
@@ -2066,33 +2150,55 @@ export default function NodePage() { )} - {hasInfoTrigger && ( -
+
{hasExpiryInfo && ( -
- - 提醒: - - - {expiryMeta.label} - - - {formatNodeRenewalTime(expiryMeta.nextDueTime).split(' ')[0]} - +
+
+
+ 到期提醒 +
+ +
+
+ + {expiryMeta.label} + +
+
+ {formatNodeRenewalTime( + expiryMeta.nextDueTime, + )} +
)} - + {hasRemark && ( -
- - 备注:{node.remark?.trim()} - +
+
+ 备注 +
+
+ {node.remark?.trim()} +
)}
@@ -2181,8 +2287,11 @@ export default function NodePage() { )} {/* 新增/编辑节点对话框 */} - 0 ? new Date(form.expiryTime).toISOString().slice(0, 10) @@ -2599,8 +2709,10 @@ export default function NodePage() { {/* 回退确认模态框 */}

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

节点将执行版本回退并自动重启,期间会导致节点短暂离线。 @@ -2635,8 +2749,11 @@ export default function NodePage() { {/* 删除确认模态框 */} - - {/* 安装命令模态框 */} - {/* 版本选择升级模态框 */} - {/* 批量删除确认模态框 */} - - {/* Create Share Modal */} - setCreateShareOpen(false)} @@ -784,7 +788,11 @@ 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 f6bb899..86a2ada 100644 --- a/vite-frontend/src/pages/profile.tsx +++ b/vite-frontend/src/pages/profile.tsx @@ -325,8 +325,11 @@ export default function ProfilePage() {

{/* 修改密码弹窗 */} - { if (open) { setBatchResultModal((prev) => ({ ...prev, open: true })); + return; } setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE); diff --git a/vite-frontend/src/pages/user.tsx b/vite-frontend/src/pages/user.tsx index 2a3a557..9a02c31 100644 --- a/vite-frontend/src/pages/user.tsx +++ b/vite-frontend/src/pages/user.tsx @@ -59,7 +59,12 @@ import { getUserGroupList, getUserGroups, } from "@/api"; -import { EditIcon, DeleteIcon, SettingsIcon, SearchIcon } from "@/components/icons"; +import { + EditIcon, + DeleteIcon, + SettingsIcon, + SearchIcon, +} from "@/components/icons"; import { PageLoadingState } from "@/components/page-state"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { removeItemsById, replaceItemById } from "@/utils/list-state"; From ac8c293ff351b40a9fcc0ad3aa9f9816667293df Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sun, 15 Mar 2026 23:01:29 +0800 Subject: [PATCH 11/13] Document forward.tsx refactoring review results --- plans/039-restore-removed-features-pr322.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/plans/039-restore-removed-features-pr322.md b/plans/039-restore-removed-features-pr322.md index 93504cd..0610cee 100644 --- a/plans/039-restore-removed-features-pr322.md +++ b/plans/039-restore-removed-features-pr322.md @@ -36,6 +36,25 @@ PR #322 (https://github.com/Sagit-chu/flvx/pull/322) 原本移除了三个功能 - `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 样式、"落地地址"文案 | ## 注意事项 From 8124e59de5fe86fab3ea86b77a6d0985fa8b4fd0 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sun, 15 Mar 2026 23:12:48 +0800 Subject: [PATCH 12/13] Roll back port column separation in forward table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge entry address:port into single '入口' column - Merge target address:port into single '目标' column - Remove separate port columns from compact table - Keep UI improvements: always show checkbox, selection highlight --- vite-frontend/src/pages/forward.tsx | 85 +++++++++-------------------- 1 file changed, 26 insertions(+), 59 deletions(-) diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 3f50a76..56d0e56 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -1015,63 +1015,36 @@ const SortableCompactTableRow = ({
- - copyToClipboard( - (forward.inIp || "").replace(/:\d+$/, "") || "默认IP", - "入口地址", - ) - } + - copyToClipboard(forward.inPort.toString(), "入口端口")} + - - 入口地址 - - 端口 - - 落地地址 - - 端口 + 入口 + 目标 策略 用量 状态 From efb613b0b5d1522ae892202247ea6186c77095d4 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sun, 15 Mar 2026 23:31:39 +0800 Subject: [PATCH 13/13] Fix TypeScript compilation errors - Add isIndeterminate support to Checkbox component - Use showAddressModal in SortableTableRow for multi-address display - Remove unused onClose parameter in search modal - Remove unused infoPopoverPlacement and related code in node.tsx --- vite-frontend/src/components/ui/checkbox.tsx | 9 ++-- vite-frontend/src/pages/forward.tsx | 48 ++++++++----------- vite-frontend/src/pages/node.tsx | 36 -------------- .../src/shadcn-bridge/heroui/checkbox.tsx | 8 +++- 4 files changed, 33 insertions(+), 68 deletions(-) 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/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 56d0e56..359c2e8 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -784,44 +784,38 @@ const SortableTableRow = ({ -
{ - e.stopPropagation(); - copyToClipboard( - (forward.inIp || "").replace(/:\d+$/, "") || "默认IP", - "入口地址", - ); + title={formatInAddress(forward.inIp, forward.inPort)} + onClick={() => { + if (hasMultipleAddresses(forward.inIp)) { + showAddressModal(forward.inIp, forward.inPort, "入口端口"); + } }} > {formatInAddress(forward.inIp, forward.inPort)} -
+
-
{ - e.stopPropagation(); - copyToClipboard( - forward.remoteAddr.split(",")[0].split(":")[0], - "落地地址", - ); + title={formatRemoteAddress(forward.remoteAddr)} + onClick={() => { + if (hasMultipleAddresses(forward.remoteAddr)) { + showAddressModal(forward.remoteAddr, null, "目标地址"); + } }} > {formatRemoteAddress(forward.remoteAddr)} -
+
- {(onClose) => ( + {() => ( <> 搜索筛选用户规则 diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 094b57e..b98733d 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -374,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) => @@ -1744,8 +1710,6 @@ export default function NodePage() { !node.expiryReminderDismissed, ); const hasInfoTrigger = hasRemark || hasExpiryInfo; - const infoCount = Number(hasExpiryInfo) + Number(hasRemark); - const infoPlacement = infoPopoverPlacement[node.id] ?? "left"; return ( diff --git a/vite-frontend/src/shadcn-bridge/heroui/checkbox.tsx b/vite-frontend/src/shadcn-bridge/heroui/checkbox.tsx index 158271a..8632da5 100644 --- a/vite-frontend/src/shadcn-bridge/heroui/checkbox.tsx +++ b/vite-frontend/src/shadcn-bridge/heroui/checkbox.tsx @@ -11,6 +11,7 @@ export interface CheckboxProps classNames?: Record; color?: string; isDisabled?: boolean; + isIndeterminate?: boolean; isSelected?: boolean; onValueChange?: (value: boolean) => void; size?: string; @@ -20,6 +21,7 @@ export function Checkbox({ children, className, isDisabled, + isIndeterminate, isSelected, onValueChange, ...props @@ -32,6 +34,10 @@ export function Checkbox({ onValueChange?.(value === true); }; + const checkedValue: boolean | "indeterminate" = isIndeterminate + ? "indeterminate" + : Boolean(isSelected); + return (