mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
feat(frontend): persist filter state across page reloads (#189)
* feat(frontend): persist filter state across page reloads Add useLocalStorageState hook to persist filter/search state in forward, node, tunnel, user, and limit pages. Reset filter clears the persisted value from localStorage. * feat(ui): add collapsible sidebar and smooth animations
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
VITE_API_BASE=
|
VITE_GITHUB_REPO=https://github.com/Sagit-chu/flvx
|
||||||
|
VITE_APP_VERSION=beta
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useCallback, useState, type SetStateAction } from "react";
|
||||||
|
|
||||||
|
const readStoredValue = <T>(key: string, fallbackValue: T): T => {
|
||||||
|
try {
|
||||||
|
const rawValue = localStorage.getItem(key);
|
||||||
|
|
||||||
|
if (rawValue === null) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(rawValue) as T;
|
||||||
|
} catch {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLocalStorageState = <T>(
|
||||||
|
key: string,
|
||||||
|
initialValue: T,
|
||||||
|
): readonly [T, (value: SetStateAction<T>) => void, () => void] => {
|
||||||
|
const [value, setValue] = useState<T>(() =>
|
||||||
|
readStoredValue(key, initialValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPersistedValue = useCallback(
|
||||||
|
(nextValue: SetStateAction<T>) => {
|
||||||
|
setValue((prevValue) => {
|
||||||
|
const resolvedValue =
|
||||||
|
typeof nextValue === "function"
|
||||||
|
? (nextValue as (value: T) => T)(prevValue)
|
||||||
|
: nextValue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(resolvedValue));
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return resolvedValue;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[key],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetPersistedValue = useCallback(() => {
|
||||||
|
setValue(initialValue);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {}
|
||||||
|
}, [initialValue, key]);
|
||||||
|
|
||||||
|
return [value, setPersistedValue, resetPersistedValue] as const;
|
||||||
|
};
|
||||||
@@ -50,6 +50,7 @@ export default function AdminLayout({
|
|||||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||||
|
|
||||||
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
|
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(() => localStorage.getItem("sidebar_collapsed") === "true");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||||
@@ -204,6 +205,13 @@ export default function AdminLayout({
|
|||||||
setMobileMenuVisible(false);
|
setMobileMenuVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 切换折叠状态
|
||||||
|
const toggleCollapse = () => {
|
||||||
|
const newCollapsed = !isCollapsed;
|
||||||
|
setIsCollapsed(newCollapsed);
|
||||||
|
localStorage.setItem("sidebar_collapsed", newCollapsed.toString());
|
||||||
|
};
|
||||||
|
|
||||||
// 菜单点击处理
|
// 菜单点击处理
|
||||||
const handleMenuClick = (path: string) => {
|
const handleMenuClick = (path: string) => {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
@@ -304,32 +312,32 @@ export default function AdminLayout({
|
|||||||
className={`
|
className={`
|
||||||
${isMobile ? "fixed" : "relative"}
|
${isMobile ? "fixed" : "relative"}
|
||||||
${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
|
${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
|
||||||
${isMobile ? "w-64" : "w-72"}
|
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-72"}
|
||||||
bg-white dark:bg-black
|
bg-white dark:bg-black
|
||||||
shadow-lg
|
shadow-lg
|
||||||
border-r border-gray-200 dark:border-gray-600
|
border-r border-gray-200 dark:border-gray-600
|
||||||
z-50
|
z-50
|
||||||
transition-transform duration-300 ease-in-out
|
transition-all duration-300 ease-in-out
|
||||||
flex flex-col
|
flex flex-col
|
||||||
${isMobile ? "h-screen" : "h-full"}
|
${isMobile ? "h-screen" : "h-full"}
|
||||||
${isMobile ? "top-0 left-0" : ""}
|
${isMobile ? "top-0 left-0" : ""}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Logo 区域 */}
|
{/* Logo 区域 */}
|
||||||
<div className="px-3 py-3 h-14 flex items-center">
|
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex-shrink-0 flex items-center justify-center w-10">
|
||||||
<Logo size={24} />
|
<Logo size={28} />
|
||||||
<div className="flex-1 min-w-0">
|
</div>
|
||||||
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap">
|
<div className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}>
|
||||||
|
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
|
||||||
{siteConfig.name}
|
{siteConfig.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-default-500">v{siteConfig.version}</p>
|
<p className="text-xs text-default-500 overflow-hidden whitespace-nowrap text-ellipsis">v{siteConfig.version}</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 菜单导航 */}
|
{/* 菜单导航 */}
|
||||||
<nav className="flex-1 px-4 py-6 overflow-y-auto">
|
<nav className="flex-1 px-3 py-6 overflow-y-auto overflow-x-hidden">
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{filteredMenuItems.map((item) => {
|
{filteredMenuItems.map((item) => {
|
||||||
const isActive = location.pathname === item.path;
|
const isActive = location.pathname === item.path;
|
||||||
@@ -337,11 +345,11 @@ export default function AdminLayout({
|
|||||||
return (
|
return (
|
||||||
<li key={item.path}>
|
<li key={item.path}>
|
||||||
<motion.button
|
<motion.button
|
||||||
|
title={isCollapsed ? item.label : undefined}
|
||||||
className={`
|
className={`
|
||||||
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
|
w-full flex items-center p-2 rounded-lg text-left
|
||||||
relative min-h-[44px]
|
relative min-h-[44px] overflow-hidden transition-colors
|
||||||
${
|
${isActive
|
||||||
isActive
|
|
||||||
? "text-primary-600 dark:text-primary-300"
|
? "text-primary-600 dark:text-primary-300"
|
||||||
: "text-gray-700 dark:text-gray-200"
|
: "text-gray-700 dark:text-gray-200"
|
||||||
}
|
}
|
||||||
@@ -367,12 +375,14 @@ export default function AdminLayout({
|
|||||||
whileHover={{ opacity: 1 }}
|
whileHover={{ opacity: 1 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-shrink-0 relative z-10">
|
<div className="flex-shrink-0 w-10 h-10 flex items-center justify-center relative z-10">
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-sm relative z-10">
|
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}>
|
||||||
|
<span className="font-medium text-sm relative z-10 whitespace-nowrap">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -380,9 +390,9 @@ export default function AdminLayout({
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 底部版权信息 */}
|
{/* 底部版权信息和折叠按钮 */}
|
||||||
<div className="px-4 py-2 pb-4 mt-auto flex-shrink-0">
|
<div className="px-5 py-2 pb-4 mt-auto flex-shrink-0 flex items-center justify-between overflow-hidden whitespace-nowrap box-border">
|
||||||
<div className="text-center">
|
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}>
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
Powered by{" "}
|
Powered by{" "}
|
||||||
<a
|
<a
|
||||||
@@ -395,6 +405,29 @@ export default function AdminLayout({
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 桌面端折叠按钮 */}
|
||||||
|
{!isMobile && (
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
className="flex-shrink-0 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 min-w-0 w-10 h-10 rounded-full ml-auto"
|
||||||
|
onPress={toggleCollapse}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
// 向右扩展的提示
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
// 向左收起的提示
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import {
|
|||||||
import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order";
|
import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order";
|
||||||
import { PageLoadingState } from "@/components/page-state";
|
import { PageLoadingState } from "@/components/page-state";
|
||||||
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
|
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
|
||||||
|
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||||
import { saveOrder } from "@/utils/order-storage";
|
import { saveOrder } from "@/utils/order-storage";
|
||||||
import { JwtUtil } from "@/utils/jwt";
|
import { JwtUtil } from "@/utils/jwt";
|
||||||
|
|
||||||
@@ -126,7 +127,10 @@ export default function ForwardPage() {
|
|||||||
const [forwards, setForwards] = useState<Forward[]>([]);
|
const [forwards, setForwards] = useState<Forward[]>([]);
|
||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||||
const isMobile = useMobileBreakpoint();
|
const isMobile = useMobileBreakpoint();
|
||||||
const [searchKeyword, setSearchKeyword] = useState("");
|
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||||
|
"forward-search-keyword",
|
||||||
|
"",
|
||||||
|
);
|
||||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
|
|
||||||
// 显示模式状态 - 从localStorage读取,默认为平铺显示
|
// 显示模式状态 - 从localStorage读取,默认为平铺显示
|
||||||
@@ -135,14 +139,16 @@ export default function ForwardPage() {
|
|||||||
const savedMode = localStorage.getItem("forward-view-mode");
|
const savedMode = localStorage.getItem("forward-view-mode");
|
||||||
|
|
||||||
return (savedMode as "grouped" | "direct") || "direct";
|
return (savedMode as "grouped" | "direct") || "direct";
|
||||||
} catch (e) {
|
} catch {
|
||||||
return "direct";
|
return "direct";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 筛选状态
|
// 筛选状态
|
||||||
const [filterUserId, setFilterUserId] = useState<string>("all");
|
const [filterUserId, setFilterUserId, resetFilterUserId] =
|
||||||
const [filterTunnelId, setFilterTunnelId] = useState<string>("all");
|
useLocalStorageState<string>("forward-filter-user-id", "all");
|
||||||
|
const [filterTunnelId, setFilterTunnelId, resetFilterTunnelId] =
|
||||||
|
useLocalStorageState<string>("forward-filter-tunnel-id", "all");
|
||||||
|
|
||||||
// 拖拽排序相关状态
|
// 拖拽排序相关状态
|
||||||
const [forwardOrder, setForwardOrder] = useState<number[]>([]);
|
const [forwardOrder, setForwardOrder] = useState<number[]>([]);
|
||||||
@@ -224,7 +230,7 @@ export default function ForwardPage() {
|
|||||||
setViewMode(newMode);
|
setViewMode(newMode);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("forward-view-mode", newMode);
|
localStorage.setItem("forward-view-mode", newMode);
|
||||||
} catch {}
|
} catch { }
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载所有数据
|
// 加载所有数据
|
||||||
@@ -1311,8 +1317,7 @@ export default function ForwardPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[220px]">
|
<TableCell className="max-w-[220px]">
|
||||||
<button
|
<button
|
||||||
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${
|
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.inIp)
|
||||||
hasMultipleAddresses(forward.inIp)
|
|
||||||
? "hover:bg-default-200 hover:shadow-sm"
|
? "hover:bg-default-200 hover:shadow-sm"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
@@ -1327,8 +1332,7 @@ export default function ForwardPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[240px]">
|
<TableCell className="max-w-[240px]">
|
||||||
<button
|
<button
|
||||||
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${
|
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.remoteAddr)
|
||||||
hasMultipleAddresses(forward.remoteAddr)
|
|
||||||
? "hover:bg-default-200 hover:shadow-sm"
|
? "hover:bg-default-200 hover:shadow-sm"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
@@ -1468,8 +1472,7 @@ export default function ForwardPage() {
|
|||||||
<div className="flex items-center gap-1.5 ml-2">
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
{viewMode === "direct" && (
|
{viewMode === "direct" && (
|
||||||
<div
|
<div
|
||||||
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${
|
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${isMobile
|
||||||
isMobile
|
|
||||||
? "opacity-100" // 移动端始终显示
|
? "opacity-100" // 移动端始终显示
|
||||||
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||||
}`}
|
}`}
|
||||||
@@ -1510,8 +1513,7 @@ export default function ForwardPage() {
|
|||||||
{/* 地址信息 */}
|
{/* 地址信息 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<button
|
<button
|
||||||
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
|
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${hasMultipleAddresses(forward.inIp)
|
||||||
hasMultipleAddresses(forward.inIp)
|
|
||||||
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
@@ -1550,8 +1552,7 @@ export default function ForwardPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
|
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${hasMultipleAddresses(forward.remoteAddr)
|
||||||
hasMultipleAddresses(forward.remoteAddr)
|
|
||||||
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
@@ -1591,9 +1592,9 @@ export default function ForwardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计信息 */}
|
{/* 统计信息 */}
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-divider">
|
<div className="flex flex-wrap items-center justify-between pt-2 border-t border-divider gap-1">
|
||||||
<Chip
|
<Chip
|
||||||
className="text-xs"
|
className="text-xs whitespace-nowrap"
|
||||||
color={strategyDisplay.color as any}
|
color={strategyDisplay.color as any}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -1602,7 +1603,7 @@ export default function ForwardPage() {
|
|||||||
</Chip>
|
</Chip>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Chip
|
<Chip
|
||||||
className="text-xs"
|
className="text-xs whitespace-nowrap"
|
||||||
color="primary"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -1611,7 +1612,7 @@ export default function ForwardPage() {
|
|||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
<Chip
|
<Chip
|
||||||
className="text-xs"
|
className="text-xs whitespace-nowrap"
|
||||||
color="success"
|
color="success"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -1977,7 +1978,7 @@ export default function ForwardPage() {
|
|||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragStart={() => {}} // 添加空的 onDragStart 处理器
|
onDragStart={() => { }} // 添加空的 onDragStart 处理器
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={sortableForwardIds}
|
items={sortableForwardIds}
|
||||||
@@ -2473,8 +2474,7 @@ export default function ForwardPage() {
|
|||||||
{importResults.map((result, index) => (
|
{importResults.map((result, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`p-2 rounded border ${
|
className={`p-2 rounded border ${result.success
|
||||||
result.success
|
|
||||||
? "bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20"
|
? "bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20"
|
||||||
: "bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20"
|
: "bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20"
|
||||||
}`}
|
}`}
|
||||||
@@ -2510,8 +2510,7 @@ export default function ForwardPage() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium ${
|
className={`text-xs font-medium ${result.success
|
||||||
result.success
|
|
||||||
? "text-success-700 dark:text-success-300"
|
? "text-success-700 dark:text-success-300"
|
||||||
: "text-danger-700 dark:text-danger-300"
|
: "text-danger-700 dark:text-danger-300"
|
||||||
}`}
|
}`}
|
||||||
@@ -2526,8 +2525,7 @@ export default function ForwardPage() {
|
|||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`text-xs ${
|
className={`text-xs ${result.success
|
||||||
result.success
|
|
||||||
? "text-success-600 dark:text-success-400"
|
? "text-success-600 dark:text-success-400"
|
||||||
: "text-danger-600 dark:text-danger-400"
|
: "text-danger-600 dark:text-danger-400"
|
||||||
}`}
|
}`}
|
||||||
@@ -2713,8 +2711,7 @@ export default function ForwardPage() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
|
||||||
result.success
|
|
||||||
? "bg-white dark:bg-gray-800"
|
? "bg-white dark:bg-gray-800"
|
||||||
: "bg-danger-50 dark:bg-danger-900/30"
|
: "bg-danger-50 dark:bg-danger-900/30"
|
||||||
}`}
|
}`}
|
||||||
@@ -2722,8 +2719,7 @@ export default function ForwardPage() {
|
|||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
|
||||||
result.success
|
|
||||||
? "bg-success text-white"
|
? "bg-success text-white"
|
||||||
: "bg-danger text-white"
|
: "bg-danger text-white"
|
||||||
}`}
|
}`}
|
||||||
@@ -2768,8 +2764,7 @@ export default function ForwardPage() {
|
|||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
{result.success ? (
|
{result.success ? (
|
||||||
<span
|
<span
|
||||||
className={`font-semibold ${
|
className={`font-semibold ${(result.packetLoss || 0) > 0
|
||||||
(result.packetLoss || 0) > 0
|
|
||||||
? "text-warning"
|
? "text-warning"
|
||||||
: "text-success"
|
: "text-success"
|
||||||
}`}
|
}`}
|
||||||
@@ -2886,16 +2881,14 @@ export default function ForwardPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`border rounded-lg p-3 ${
|
className={`border rounded-lg p-3 ${result.success
|
||||||
result.success
|
|
||||||
? "border-divider bg-white dark:bg-gray-800"
|
? "border-divider bg-white dark:bg-gray-800"
|
||||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2 mb-2">
|
<div className="flex items-start gap-2 mb-2">
|
||||||
<span
|
<span
|
||||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
|
||||||
result.success
|
|
||||||
? "bg-success text-white"
|
? "bg-success text-white"
|
||||||
: "bg-danger text-white"
|
: "bg-danger text-white"
|
||||||
}`}
|
}`}
|
||||||
@@ -2934,8 +2927,7 @@ export default function ForwardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div
|
<div
|
||||||
className={`text-lg font-bold ${
|
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
|
||||||
(result.packetLoss || 0) > 0
|
|
||||||
? "text-warning"
|
? "text-warning"
|
||||||
: "text-success"
|
: "text-success"
|
||||||
}`}
|
}`}
|
||||||
@@ -3218,8 +3210,8 @@ export default function ForwardPage() {
|
|||||||
color="default"
|
color="default"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setFilterUserId("all");
|
resetFilterUserId();
|
||||||
setFilterTunnelId("all");
|
resetFilterTunnelId();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
getTunnelList,
|
getTunnelList,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { PageLoadingState } from "@/components/page-state";
|
import { PageLoadingState } from "@/components/page-state";
|
||||||
|
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||||
|
|
||||||
interface SpeedLimitRule {
|
interface SpeedLimitRule {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -57,7 +58,10 @@ export default function LimitPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||||
const [searchKeyword, setSearchKeyword] = useState("");
|
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||||
|
"limit-search-keyword",
|
||||||
|
"",
|
||||||
|
);
|
||||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
|
|
||||||
const filteredRules = useMemo(() => {
|
const filteredRules = useMemo(() => {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { tryCopyInstallCommand } from "@/pages/node/install-command";
|
|||||||
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
||||||
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
||||||
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||||
|
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
@@ -158,7 +159,10 @@ export default function NodePage() {
|
|||||||
const [nodeList, setNodeList] = useState<Node[]>([]);
|
const [nodeList, setNodeList] = useState<Node[]>([]);
|
||||||
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
|
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchKeyword, setSearchKeyword] = useState("");
|
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||||
|
"node-search-keyword",
|
||||||
|
"",
|
||||||
|
);
|
||||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
const [dialogVisible, setDialogVisible] = useState(false);
|
const [dialogVisible, setDialogVisible] = useState(false);
|
||||||
const [dialogTitle, setDialogTitle] = useState("");
|
const [dialogTitle, setDialogTitle] = useState("");
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { AnimatedPage } from "@/components/animated-page";
|
|
||||||
import { SearchBar } from "@/components/search-bar";
|
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
@@ -19,6 +17,9 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
import { SearchBar } from "@/components/search-bar";
|
||||||
|
import { AnimatedPage } from "@/components/animated-page";
|
||||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
||||||
@@ -58,6 +59,7 @@ import {
|
|||||||
getTunnelTypeDisplay,
|
getTunnelTypeDisplay,
|
||||||
validateTunnelForm,
|
validateTunnelForm,
|
||||||
} from "@/pages/tunnel/form";
|
} from "@/pages/tunnel/form";
|
||||||
|
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||||
import { extractApiErrorMessage } from "@/api/error-message";
|
import { extractApiErrorMessage } from "@/api/error-message";
|
||||||
|
|
||||||
@@ -112,7 +114,10 @@ export default function TunnelPage() {
|
|||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||||
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
|
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
|
||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
const [searchKeyword, setSearchKeyword] = useState("");
|
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||||
|
"tunnel-search-keyword",
|
||||||
|
"",
|
||||||
|
);
|
||||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
|
|
||||||
// 模态框状态
|
// 模态框状态
|
||||||
@@ -604,9 +609,11 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
if (searchKeyword.trim()) {
|
if (searchKeyword.trim()) {
|
||||||
const lowerKeyword = searchKeyword.toLowerCase();
|
const lowerKeyword = searchKeyword.toLowerCase();
|
||||||
filteredTunnels = filteredTunnels.filter(t =>
|
|
||||||
|
filteredTunnels = filteredTunnels.filter(
|
||||||
|
(t) =>
|
||||||
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
|
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
|
||||||
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword))
|
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import { PageLoadingState } from "@/components/page-state";
|
import { PageLoadingState } from "@/components/page-state";
|
||||||
|
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||||
|
|
||||||
// 工具函数
|
// 工具函数
|
||||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||||
@@ -125,7 +126,10 @@ export default function UserPage() {
|
|||||||
// 状态管理
|
// 状态管理
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchKeyword, setSearchKeyword] = useState("");
|
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||||
|
"user-search-keyword",
|
||||||
|
"",
|
||||||
|
);
|
||||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
const [pagination, setPagination] = useState<PaginationType>({
|
const [pagination, setPagination] = useState<PaginationType>({
|
||||||
current: 1,
|
current: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user