Compare commits

...

3 Commits

Author SHA1 Message Date
sagit 42ae3457b5 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
2026-02-22 19:01:03 +08:00
sagit 088027da7b fix(frontend): resolve remaining font blur in node and forward cards (#188)
## Summary
- apply the same anti-blur drag transform strategy used in tunnel cards
to node cards
- update forward card and table-row sortable transforms to round x/y
pixels and reduce subpixel text blur
- limit `willChange` usage to active dragging so text does not remain on
a promoted layer while idle

## Verification
- npm run build (vite-frontend)
2026-02-22 16:55:08 +08:00
sagitchu d37adee5df fix(frontend): resolve remaining font blur in node and forward cards 2026-02-22 16:53:35 +08:00
8 changed files with 268 additions and 160 deletions
+2 -1
View File
@@ -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;
};
+59 -26
View File
@@ -50,6 +50,7 @@ export default function AdminLayout({
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(() => localStorage.getItem("sidebar_collapsed") === "true");
const [username, setUsername] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
@@ -204,6 +205,13 @@ export default function AdminLayout({
setMobileMenuVisible(false);
};
// 切换折叠状态
const toggleCollapse = () => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
localStorage.setItem("sidebar_collapsed", newCollapsed.toString());
};
// 菜单点击处理
const handleMenuClick = (path: string) => {
navigate(path);
@@ -304,32 +312,32 @@ export default function AdminLayout({
className={`
${isMobile ? "fixed" : "relative"}
${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
shadow-lg
border-r border-gray-200 dark:border-gray-600
z-50
transition-transform duration-300 ease-in-out
transition-all duration-300 ease-in-out
flex flex-col
${isMobile ? "h-screen" : "h-full"}
${isMobile ? "top-0 left-0" : ""}
`}
>
{/* Logo 区域 */}
<div className="px-3 py-3 h-14 flex items-center">
<div className="flex items-center gap-2 w-full">
<Logo size={24} />
<div className="flex-1 min-w-0">
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap">
{siteConfig.name}
</h1>
<p className="text-xs text-default-500">v{siteConfig.version}</p>
</div>
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
<div className="flex-shrink-0 flex items-center justify-center w-10">
<Logo size={28} />
</div>
<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}
</h1>
<p className="text-xs text-default-500 overflow-hidden whitespace-nowrap text-ellipsis">v{siteConfig.version}</p>
</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">
{filteredMenuItems.map((item) => {
const isActive = location.pathname === item.path;
@@ -337,14 +345,14 @@ export default function AdminLayout({
return (
<li key={item.path}>
<motion.button
title={isCollapsed ? item.label : undefined}
className={`
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
relative min-h-[44px]
${
isActive
? "text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200"
}
w-full flex items-center p-2 rounded-lg text-left
relative min-h-[44px] overflow-hidden transition-colors
${isActive
? "text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200"
}
`}
transition={{ duration: 0.15 }}
onClick={() => handleMenuClick(item.path)}
@@ -367,12 +375,14 @@ export default function AdminLayout({
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}
</div>
<span className="font-medium text-sm relative z-10">
{item.label}
</span>
<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}
</span>
</div>
</motion.button>
</li>
);
@@ -380,9 +390,9 @@ export default function AdminLayout({
</ul>
</nav>
{/* 底部版权信息 */}
<div className="px-4 py-2 pb-4 mt-auto flex-shrink-0">
<div className="text-center">
{/* 底部版权信息和折叠按钮 */}
<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={`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">
Powered by{" "}
<a
@@ -395,6 +405,29 @@ export default function AdminLayout({
</a>
</p>
</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>
</aside>
+120 -119
View File
@@ -80,6 +80,7 @@ import {
import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order";
import { PageLoadingState } from "@/components/page-state";
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { saveOrder } from "@/utils/order-storage";
import { JwtUtil } from "@/utils/jwt";
@@ -126,7 +127,10 @@ export default function ForwardPage() {
const [forwards, setForwards] = useState<Forward[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const isMobile = useMobileBreakpoint();
const [searchKeyword, setSearchKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"forward-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
// 显示模式状态 - 从localStorage读取,默认为平铺显示
@@ -135,14 +139,16 @@ export default function ForwardPage() {
const savedMode = localStorage.getItem("forward-view-mode");
return (savedMode as "grouped" | "direct") || "direct";
} catch (e) {
} catch {
return "direct";
}
});
// 筛选状态
const [filterUserId, setFilterUserId] = useState<string>("all");
const [filterTunnelId, setFilterTunnelId] = useState<string>("all");
const [filterUserId, setFilterUserId, resetFilterUserId] =
useLocalStorageState<string>("forward-filter-user-id", "all");
const [filterTunnelId, setFilterTunnelId, resetFilterTunnelId] =
useLocalStorageState<string>("forward-filter-tunnel-id", "all");
// 拖拽排序相关状态
const [forwardOrder, setForwardOrder] = useState<number[]>([]);
@@ -224,7 +230,7 @@ export default function ForwardPage() {
setViewMode(newMode);
try {
localStorage.setItem("forward-view-mode", newMode);
} catch {}
} catch { }
};
// 加载所有数据
@@ -1188,13 +1194,16 @@ export default function ForwardPage() {
} = useSortable({ id: forward.id });
const style: React.CSSProperties = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
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: "transform",
backfaceVisibility: "hidden",
WebkitFontSmoothing: "antialiased",
MozOsxFontSmoothing: "grayscale",
willChange: isDragging ? "transform" : undefined,
};
return (
@@ -1246,7 +1255,13 @@ export default function ForwardPage() {
} = useSortable({ id: forward.id });
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
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,
@@ -1302,11 +1317,10 @@ export default function ForwardPage() {
</TableCell>
<TableCell className="max-w-[220px]">
<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 ${
hasMultipleAddresses(forward.inIp)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
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)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatInAddress(forward.inIp, forward.inPort)}
type="button"
onClick={() =>
@@ -1318,11 +1332,10 @@ export default function ForwardPage() {
</TableCell>
<TableCell className="max-w-[240px]">
<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 ${
hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
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)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatRemoteAddress(forward.remoteAddr)}
type="button"
onClick={() =>
@@ -1459,11 +1472,10 @@ export default function ForwardPage() {
<div className="flex items-center gap-1.5 ml-2">
{viewMode === "direct" && (
<div
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${
isMobile
? "opacity-100" // 移动端始终显示
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
}`}
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${isMobile
? "opacity-100" // 移动端始终显示
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
}`}
{...listeners}
style={{ touchAction: "none" }}
title={isMobile ? "长按拖拽排序" : "拖拽排序"}
@@ -1501,11 +1513,10 @@ export default function ForwardPage() {
{/* 地址信息 */}
<div className="space-y-1">
<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 ${
hasMultipleAddresses(forward.inIp)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
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)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
title={formatInAddress(forward.inIp, forward.inPort)}
type="button"
onClick={() =>
@@ -1541,11 +1552,10 @@ export default function ForwardPage() {
</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 ${
hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
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)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
title={formatRemoteAddress(forward.remoteAddr)}
type="button"
onClick={() =>
@@ -1582,9 +1592,9 @@ export default function ForwardPage() {
</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
className="text-xs"
className="text-xs whitespace-nowrap"
color={strategyDisplay.color as any}
size="sm"
variant="flat"
@@ -1593,7 +1603,7 @@ export default function ForwardPage() {
</Chip>
<div className="flex items-center gap-1">
<Chip
className="text-xs"
className="text-xs whitespace-nowrap"
color="primary"
size="sm"
variant="flat"
@@ -1602,7 +1612,7 @@ export default function ForwardPage() {
</Chip>
</div>
<Chip
className="text-xs"
className="text-xs whitespace-nowrap"
color="success"
size="sm"
variant="flat"
@@ -1963,39 +1973,39 @@ export default function ForwardPage() {
</Card>
)
) : /* 直接显示模式 */
forwards.length > 0 ? (
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}} // 添加空的 onDragStart 处理器
>
<SortableContext
items={sortableForwardIds}
strategy={rectSortingStrategy}
forwards.length > 0 ? (
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => { }} // 添加空的 onDragStart 处理器
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedForwards.map((forward) =>
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null,
)}
</div>
</SortableContext>
</DndContext>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无转发配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何转发配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)}
<SortableContext
items={sortableForwardIds}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedForwards.map((forward) =>
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null,
)}
</div>
</SortableContext>
</DndContext>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无转发配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何转发配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)}
{/* 新增/编辑模态框 */}
<Modal
@@ -2464,11 +2474,10 @@ export default function ForwardPage() {
{importResults.map((result, index) => (
<div
key={index}
className={`p-2 rounded border ${
result.success
? "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"
}`}
className={`p-2 rounded border ${result.success
? "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"
}`}
>
<div className="flex items-center gap-2">
{result.success ? (
@@ -2501,11 +2510,10 @@ export default function ForwardPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`text-xs font-medium ${
result.success
? "text-success-700 dark:text-success-300"
: "text-danger-700 dark:text-danger-300"
}`}
className={`text-xs font-medium ${result.success
? "text-success-700 dark:text-success-300"
: "text-danger-700 dark:text-danger-300"
}`}
>
{result.success ? "成功" : "失败"}
</span>
@@ -2517,11 +2525,10 @@ export default function ForwardPage() {
</code>
</div>
<div
className={`text-xs ${
result.success
? "text-success-600 dark:text-success-400"
: "text-danger-600 dark:text-danger-400"
}`}
className={`text-xs ${result.success
? "text-success-600 dark:text-success-400"
: "text-danger-600 dark:text-danger-400"
}`}
>
{result.message}
</div>
@@ -2704,20 +2711,18 @@ export default function ForwardPage() {
return (
<tr
key={index}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
result.success
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
@@ -2759,11 +2764,10 @@ export default function ForwardPage() {
<td className="px-3 py-2 text-center">
{result.success ? (
<span
className={`font-semibold ${
(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
className={`font-semibold ${(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
>
{result.packetLoss?.toFixed(1)}%
</span>
@@ -2877,19 +2881,17 @@ export default function ForwardPage() {
return (
<div
key={index}
className={`border rounded-lg p-3 ${
result.success
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
className={`border rounded-lg p-3 ${result.success
? "border-divider bg-white dark:bg-gray-800"
: "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">
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
@@ -2925,11 +2927,10 @@ export default function ForwardPage() {
</div>
<div className="text-center">
<div
className={`text-lg font-bold ${
(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
>
{result.packetLoss?.toFixed(1)}%
</div>
@@ -3209,8 +3210,8 @@ export default function ForwardPage() {
color="default"
variant="flat"
onPress={() => {
setFilterUserId("all");
setFilterTunnelId("all");
resetFilterUserId();
resetFilterTunnelId();
}}
>
重置
+5 -1
View File
@@ -27,6 +27,7 @@ import {
getTunnelList,
} from "@/api";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
interface SpeedLimitRule {
id: number;
@@ -57,7 +58,10 @@ export default function LimitPage() {
const [loading, setLoading] = useState(true);
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [searchKeyword, setSearchKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"limit-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const filteredRules = useMemo(() => {
+13 -6
View File
@@ -62,6 +62,7 @@ import { tryCopyInstallCommand } from "@/pages/node/install-command";
import { buildNodeSystemInfo } from "@/pages/node/system-info";
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
interface Node {
@@ -130,13 +131,16 @@ const SortableItem = ({
} = useSortable({ id });
const style: React.CSSProperties = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
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: "transform",
backfaceVisibility: "hidden",
WebkitFontSmoothing: "antialiased",
MozOsxFontSmoothing: "grayscale",
willChange: isDragging ? "transform" : undefined,
};
return (
@@ -155,7 +159,10 @@ export default function NodePage() {
const [nodeList, setNodeList] = useState<Node[]>([]);
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"node-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false);
const [dialogTitle, setDialogTitle] = useState("");
+13 -6
View File
@@ -1,7 +1,5 @@
import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { SearchBar } from "@/components/search-bar";
import {
DndContext,
KeyboardSensor,
@@ -19,6 +17,9 @@ import {
useSortable,
} from "@dnd-kit/sortable";
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 { Button } from "@/shadcn-bridge/heroui/button";
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
@@ -58,6 +59,7 @@ import {
getTunnelTypeDisplay,
validateTunnelForm,
} 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";
@@ -112,7 +114,10 @@ export default function TunnelPage() {
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
const [nodes, setNodes] = useState<Node[]>([]);
const [searchKeyword, setSearchKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"tunnel-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
// 模态框状态
@@ -604,9 +609,11 @@ export default function TunnelPage() {
if (searchKeyword.trim()) {
const lowerKeyword = searchKeyword.toLowerCase();
filteredTunnels = filteredTunnels.filter(t =>
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword))
filteredTunnels = filteredTunnels.filter(
(t) =>
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword)),
);
}
+5 -1
View File
@@ -65,6 +65,7 @@ import {
SettingsIcon,
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
// 工具函数
const formatFlow = (value: number, unit: string = "bytes"): string => {
@@ -125,7 +126,10 @@ export default function UserPage() {
// 状态管理
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"user-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [pagination, setPagination] = useState<PaginationType>({
current: 1,