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}