mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-04 17:16:37 +08:00
fix(frontend): switch grouped forward view to full list (#161)
* fix(frontend): left-align announcement logo in homepage banner * fix(frontend): keep multiselect panels floating and preserve summaries * fix(frontend): rebalance config card header spacing * fix(frontend): normalize formatting and fix multiselect modal behavior * fix(frontend): polish batch actions and sharing page guidance * fix(frontend): switch grouped forward view to full list * fix(backend): switch diagnosis internet target to bing * style(frontend): beautify forward group view list display * style: remove 'x' suffix from traffic ratio input and center config alert * fix(ui): expand exit node select upwards in modal * style: fix vertical alignment of logo and title in announcement banner
This commit is contained in:
@@ -481,7 +481,7 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
|||||||
case 1:
|
case 1:
|
||||||
for _, inNode := range inNodes {
|
for _, inNode := range inNodes {
|
||||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||||
"fromChainType": 1,
|
"fromChainType": 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -534,14 +534,14 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
|||||||
|
|
||||||
for _, outNode := range outNodes {
|
for _, outNode := range outNodes {
|
||||||
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
|
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||||
"fromChainType": 3,
|
"fromChainType": 3,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
for _, inNode := range inNodes {
|
for _, inNode := range inNodes {
|
||||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||||
"fromChainType": 1,
|
"fromChainType": 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ const getInitialConfigs = (): Record<string, string> => {
|
|||||||
initialConfigs[key] = cachedValue;
|
initialConfigs[key] = cachedValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch { }
|
||||||
|
|
||||||
return initialConfigs;
|
return initialConfigs;
|
||||||
};
|
};
|
||||||
@@ -528,11 +528,10 @@ export default function ConfigPage() {
|
|||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${
|
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${isSelected
|
||||||
isSelected
|
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||||
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||||
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
}`}
|
||||||
}`}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleTypeSelection(option.value, setTypes)}
|
onClick={() => toggleTypeSelection(option.value, setTypes)}
|
||||||
>
|
>
|
||||||
@@ -546,11 +545,10 @@ export default function ConfigPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`font-medium ${
|
className={`font-medium ${isSelected
|
||||||
isSelected
|
? "text-default-900 dark:text-default-100"
|
||||||
? "text-default-900 dark:text-default-100"
|
: "text-default-700 dark:text-default-500"
|
||||||
: "text-default-700 dark:text-default-500"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -649,9 +647,9 @@ export default function ConfigPage() {
|
|||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
||||||
<CardBody className="py-3">
|
<CardBody className="py-3">
|
||||||
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
|
<div className="w-full flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
|
||||||
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
|
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
|
||||||
<span className="text-sm">
|
<span className="text-sm font-medium">
|
||||||
检测到配置变更,请记得保存您的修改
|
检测到配置变更,请记得保存您的修改
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export const AnnouncementBanner = ({
|
|||||||
return (
|
return (
|
||||||
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
||||||
<CardBody className="p-4">
|
<CardBody className="p-4">
|
||||||
<div className="flex items-center justify-start gap-3">
|
<div className="flex items-start justify-start gap-3.5">
|
||||||
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0 flex items-center justify-center mt-0.5">
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||||
@@ -31,11 +31,11 @@ export const AnnouncementBanner = ({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 pt-0.5">
|
||||||
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
|
<h3 className="text-base font-semibold leading-none text-blue-900 dark:text-blue-100 mb-1.5">
|
||||||
公告
|
公告
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
|
<p className="text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words leading-relaxed">
|
||||||
{announcement.content}
|
{announcement.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+276
-222
@@ -24,6 +24,14 @@ import { Button } from "@/shadcn-bridge/heroui/button";
|
|||||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||||
import { Textarea } from "@/shadcn-bridge/heroui/input";
|
import { Textarea } from "@/shadcn-bridge/heroui/input";
|
||||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shadcn-bridge/heroui/table";
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
@@ -35,7 +43,6 @@ import { Chip } from "@/shadcn-bridge/heroui/chip";
|
|||||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||||
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
|
|
||||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||||
import {
|
import {
|
||||||
createForward,
|
createForward,
|
||||||
@@ -235,7 +242,7 @@ export default function ForwardPage() {
|
|||||||
saveOrder(FORWARD_ORDER_KEY, order);
|
saveOrder(FORWARD_ORDER_KEY, order);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载所有数据
|
// 加载所有数据
|
||||||
@@ -1282,11 +1289,10 @@ 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"
|
}`}
|
||||||
}`}
|
|
||||||
{...listeners}
|
{...listeners}
|
||||||
style={{ touchAction: "none" }}
|
style={{ touchAction: "none" }}
|
||||||
title={isMobile ? "长按拖拽排序" : "拖拽排序"}
|
title={isMobile ? "长按拖拽排序" : "拖拽排序"}
|
||||||
@@ -1324,11 +1330,10 @@ 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"
|
: ""
|
||||||
: ""
|
}`}
|
||||||
}`}
|
|
||||||
title={formatInAddress(forward.inIp, forward.inPort)}
|
title={formatInAddress(forward.inIp, forward.inPort)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -1364,11 +1369,10 @@ 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"
|
: ""
|
||||||
: ""
|
}`}
|
||||||
}`}
|
|
||||||
title={formatRemoteAddress(forward.remoteAddr)}
|
title={formatRemoteAddress(forward.remoteAddr)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -1516,8 +1520,6 @@ export default function ForwardPage() {
|
|||||||
return <PageLoadingState message="正在加载..." />;
|
return <PageLoadingState message="正在加载..." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userGroups = groupForwardsByUserAndTunnel();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 lg:px-6 py-8">
|
<div className="px-3 lg:px-6 py-8">
|
||||||
{/* 页面头部 */}
|
{/* 页面头部 */}
|
||||||
@@ -1584,7 +1586,7 @@ export default function ForwardPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="default"
|
color={selectMode ? "secondary" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selectMode ? "solid" : "flat"}
|
variant={selectMode ? "solid" : "flat"}
|
||||||
onPress={toggleSelectMode}
|
onPress={toggleSelectMode}
|
||||||
@@ -1669,116 +1671,177 @@ export default function ForwardPage() {
|
|||||||
|
|
||||||
{/* 根据显示模式渲染不同内容 */}
|
{/* 根据显示模式渲染不同内容 */}
|
||||||
{viewMode === "grouped" ? (
|
{viewMode === "grouped" ? (
|
||||||
/* 按用户和隧道分组的转发列表 */
|
sortedForwards.length > 0 ? (
|
||||||
userGroups.length > 0 ? (
|
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
|
||||||
<div className="space-y-6">
|
<Table
|
||||||
{userGroups.map((userGroup) => (
|
aria-label="全部转发列表"
|
||||||
<Card
|
classNames={{
|
||||||
key={userGroup.userId || "unknown"}
|
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||||
className="shadow-sm border border-divider w-full overflow-hidden"
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
>
|
tr: "hover:bg-default-50/50 transition-colors",
|
||||||
<CardHeader className="pb-2 md:pb-2">
|
}}
|
||||||
<div className="flex items-center justify-between w-full min-w-0">
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<TableHeader>
|
||||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0">
|
{selectMode && <TableColumn className="w-14">选择</TableColumn>}
|
||||||
<svg
|
<TableColumn>用户</TableColumn>
|
||||||
aria-hidden="true"
|
<TableColumn>隧道</TableColumn>
|
||||||
className="w-5 h-5 text-primary"
|
<TableColumn>名称</TableColumn>
|
||||||
fill="currentColor"
|
<TableColumn>入口</TableColumn>
|
||||||
viewBox="0 0 20 20"
|
<TableColumn>目标</TableColumn>
|
||||||
>
|
<TableColumn>策略</TableColumn>
|
||||||
<path
|
<TableColumn>流量</TableColumn>
|
||||||
clipRule="evenodd"
|
<TableColumn>状态</TableColumn>
|
||||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
<TableColumn className="text-right">操作</TableColumn>
|
||||||
fillRule="evenodd"
|
</TableHeader>
|
||||||
/>
|
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
|
||||||
</svg>
|
{(forward) => {
|
||||||
</div>
|
const statusDisplay = getStatusDisplay(forward.status);
|
||||||
<div className="min-w-0 flex-1">
|
const strategyDisplay = getStrategyDisplay(forward.strategy);
|
||||||
<h2 className="text-base font-medium text-foreground truncate max-w-[150px] sm:max-w-[250px] md:max-w-[350px] lg:max-w-[450px]">
|
|
||||||
{userGroup.userName}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-default-500 truncate max-w-[150px] sm:max-w-[250px] md:max-w-[350px] lg:max-w-[450px]">
|
|
||||||
{userGroup.tunnelGroups.length} 个隧道,
|
|
||||||
{userGroup.tunnelGroups.reduce(
|
|
||||||
(total, tg) => total + tg.forwards.length,
|
|
||||||
0,
|
|
||||||
)}{" "}
|
|
||||||
个转发
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Chip
|
|
||||||
className="text-xs flex-shrink-0 ml-2"
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
>
|
|
||||||
用户
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody className="pt-0 md:pt-0">
|
return (
|
||||||
<Accordion className="px-0" variant="splitted">
|
<TableRow key={forward.id}>
|
||||||
{userGroup.tunnelGroups.map((tunnelGroup) => (
|
{selectMode && (
|
||||||
<AccordionItem
|
<TableCell>
|
||||||
key={tunnelGroup.tunnelId}
|
<Checkbox
|
||||||
aria-label={tunnelGroup.tunnelName}
|
isSelected={selectedIds.has(forward.id)}
|
||||||
className="shadow-none border border-divider"
|
onValueChange={() => toggleSelect(forward.id)}
|
||||||
title={
|
/>
|
||||||
<div className="flex items-center justify-between w-full min-w-0 pr-4">
|
</TableCell>
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
)}
|
||||||
<div className="w-8 h-8 bg-success-100 dark:bg-success-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
<TableCell className="whitespace-nowrap">
|
||||||
<svg
|
<div className="flex items-center gap-2">
|
||||||
aria-hidden="true"
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
||||||
className="w-4 h-4 text-success"
|
{(forward.userName || "未")[0].toUpperCase()}
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="text-sm font-medium text-foreground truncate max-w-[120px] sm:max-w-[200px] md:max-w-[300px] lg:max-w-[400px]">
|
|
||||||
{tunnelGroup.tunnelName}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
|
||||||
<Chip
|
|
||||||
className="text-xs"
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
>
|
|
||||||
{
|
|
||||||
tunnelGroup.forwards.filter(
|
|
||||||
(f) => f.serviceRunning,
|
|
||||||
).length
|
|
||||||
}
|
|
||||||
/{tunnelGroup.forwards.length}
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
<span className="text-sm font-medium text-default-700">
|
||||||
>
|
{forward.userName || "未知用户"}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 p-4">
|
</span>
|
||||||
{tunnelGroup.forwards.map((forward) =>
|
|
||||||
renderForwardCard(forward, undefined),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionItem>
|
</TableCell>
|
||||||
))}
|
<TableCell className="whitespace-nowrap">
|
||||||
</Accordion>
|
<Chip
|
||||||
</CardBody>
|
size="sm"
|
||||||
</Card>
|
color="secondary"
|
||||||
))}
|
className="border-none bg-secondary/10 px-2"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-secondary-700">{forward.tunnelName}</span>
|
||||||
|
</Chip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap font-semibold text-foreground">
|
||||||
|
{forward.name}
|
||||||
|
</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"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
title={formatInAddress(forward.inIp, forward.inPort)}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
showAddressModal(forward.inIp, forward.inPort, "入口端口")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatInAddress(forward.inIp, forward.inPort)}
|
||||||
|
</button>
|
||||||
|
</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"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
title={formatRemoteAddress(forward.remoteAddr)}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
showAddressModal(forward.remoteAddr, null, "目标地址")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatRemoteAddress(forward.remoteAddr)}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
className="text-xs font-medium"
|
||||||
|
color={strategyDisplay.color as any}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{strategyDisplay.text}
|
||||||
|
</Chip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
<div className="flex flex-col gap-1.5 text-xs font-medium text-default-600">
|
||||||
|
<span className="flex items-center gap-1 text-success-600">
|
||||||
|
<span className="text-[10px]">↑</span>
|
||||||
|
{formatFlow(forward.inFlow || 0)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-primary-600">
|
||||||
|
<span className="text-[10px]">↓</span>
|
||||||
|
{formatFlow(forward.outFlow || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2.5 whitespace-nowrap">
|
||||||
|
<Switch
|
||||||
|
isDisabled={forward.status !== 1 && forward.status !== 0}
|
||||||
|
isSelected={forward.serviceRunning}
|
||||||
|
size="sm"
|
||||||
|
color="success"
|
||||||
|
onValueChange={() => handleServiceToggle(forward)}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
className="border-none text-xs font-medium"
|
||||||
|
color={statusDisplay.color as any}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{statusDisplay.text}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className="bg-primary/10 text-primary hover:bg-primary/20"
|
||||||
|
size="sm"
|
||||||
|
title="编辑"
|
||||||
|
onPress={() => handleEdit(forward)}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className="bg-warning/10 text-warning hover:bg-warning/20"
|
||||||
|
size="sm"
|
||||||
|
title="诊断"
|
||||||
|
onPress={() => handleDiagnose(forward)}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className="bg-danger/10 text-danger hover:bg-danger/20"
|
||||||
|
size="sm"
|
||||||
|
title="删除"
|
||||||
|
onPress={() => handleDelete(forward)}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
@@ -1814,59 +1877,59 @@ export default function ForwardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
) : /* 直接显示模式 */
|
) : /* 直接显示模式 */
|
||||||
forwards.length > 0 ? (
|
forwards.length > 0 ? (
|
||||||
<DndContext
|
<DndContext
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragStart={() => {}} // 添加空的 onDragStart 处理器
|
onDragStart={() => { }} // 添加空的 onDragStart 处理器
|
||||||
>
|
|
||||||
<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">
|
<SortableContext
|
||||||
{sortedForwards.map((forward) =>
|
items={sortableForwardIds}
|
||||||
forward && forward.id ? (
|
strategy={rectSortingStrategy}
|
||||||
<SortableForwardCard key={forward.id} forward={forward} />
|
>
|
||||||
) : null,
|
<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) =>
|
||||||
</div>
|
forward && forward.id ? (
|
||||||
</SortableContext>
|
<SortableForwardCard key={forward.id} forward={forward} />
|
||||||
</DndContext>
|
) : null,
|
||||||
) : (
|
)}
|
||||||
/* 空状态 */
|
|
||||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
|
||||||
<CardBody className="text-center py-16">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-8 h-8 text-default-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</SortableContext>
|
||||||
<h3 className="text-lg font-semibold text-foreground">
|
</DndContext>
|
||||||
暂无转发配置
|
) : (
|
||||||
</h3>
|
/* 空状态 */
|
||||||
<p className="text-default-500 text-sm mt-1">
|
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
还没有创建任何转发配置,点击上方按钮开始创建
|
<CardBody className="text-center py-16">
|
||||||
</p>
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-8 h-8 text-default-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
|
暂无转发配置
|
||||||
|
</h3>
|
||||||
|
<p className="text-default-500 text-sm mt-1">
|
||||||
|
还没有创建任何转发配置,点击上方按钮开始创建
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardBody>
|
||||||
</CardBody>
|
</Card>
|
||||||
</Card>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 新增/编辑模态框 */}
|
{/* 新增/编辑模态框 */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -2335,11 +2398,10 @@ 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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{result.success ? (
|
{result.success ? (
|
||||||
@@ -2372,11 +2434,10 @@ 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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{result.success ? "成功" : "失败"}
|
{result.success ? "成功" : "失败"}
|
||||||
</span>
|
</span>
|
||||||
@@ -2388,11 +2449,10 @@ 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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{result.message}
|
{result.message}
|
||||||
</div>
|
</div>
|
||||||
@@ -2575,20 +2635,18 @@ 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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{result.success ? "✓" : "✗"}
|
{result.success ? "✓" : "✗"}
|
||||||
</span>
|
</span>
|
||||||
@@ -2630,11 +2688,10 @@ 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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{result.packetLoss?.toFixed(1)}%
|
{result.packetLoss?.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
@@ -2748,19 +2805,17 @@ 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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{result.success ? "✓" : "✗"}
|
{result.success ? "✓" : "✗"}
|
||||||
</span>
|
</span>
|
||||||
@@ -2796,11 +2851,10 @@ 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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{result.packetLoss?.toFixed(1)}%
|
{result.packetLoss?.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1000,7 +1000,7 @@ export default function NodePage() {
|
|||||||
|
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Button
|
<Button
|
||||||
color="default"
|
color={selectMode ? "secondary" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selectMode ? "solid" : "flat"}
|
variant={selectMode ? "solid" : "flat"}
|
||||||
onPress={toggleSelectMode}
|
onPress={toggleSelectMode}
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ export default function PanelSharingPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardBody className="space-y-5 pt-4 md:pt-6">
|
<CardBody className="space-y-5">
|
||||||
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
@@ -554,7 +554,7 @@ export default function PanelSharingPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardBody className="space-y-5 pt-4 md:pt-6">
|
<CardBody className="space-y-5">
|
||||||
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
|||||||
@@ -226,9 +226,9 @@ export default function TunnelPage() {
|
|||||||
trafficRatio: tunnel.trafficRatio,
|
trafficRatio: tunnel.trafficRatio,
|
||||||
inIp: tunnel.inIp
|
inIp: tunnel.inIp
|
||||||
? tunnel.inIp
|
? tunnel.inIp
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((ip: string) => ip.trim())
|
.map((ip: string) => ip.trim())
|
||||||
.join("\n")
|
.join("\n")
|
||||||
: "",
|
: "",
|
||||||
ipPreference: tunnel.ipPreference || "",
|
ipPreference: tunnel.ipPreference || "",
|
||||||
status: tunnel.status,
|
status: tunnel.status,
|
||||||
@@ -678,7 +678,7 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
color="default"
|
color={selectMode ? "secondary" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selectMode ? "solid" : "flat"}
|
variant={selectMode ? "solid" : "flat"}
|
||||||
onPress={toggleSelectMode}
|
onPress={toggleSelectMode}
|
||||||
@@ -1127,11 +1127,6 @@ export default function TunnelPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
endContent={
|
|
||||||
<div className="pointer-events-none flex items-center">
|
|
||||||
<span className="text-default-400 text-small">x</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
errorMessage={errors.trafficRatio}
|
errorMessage={errors.trafficRatio}
|
||||||
isInvalid={!!errors.trafficRatio}
|
isInvalid={!!errors.trafficRatio}
|
||||||
label="流量倍率"
|
label="流量倍率"
|
||||||
@@ -1386,6 +1381,7 @@ export default function TunnelPage() {
|
|||||||
]}
|
]}
|
||||||
label="节点"
|
label="节点"
|
||||||
placeholder="选择节点(可多选)"
|
placeholder="选择节点(可多选)"
|
||||||
|
dropdownPlacement="top"
|
||||||
selectedKeys={groupNodes
|
selectedKeys={groupNodes
|
||||||
.filter((ct) => ct.nodeId !== -1)
|
.filter((ct) => ct.nodeId !== -1)
|
||||||
.map((ct) => ct.nodeId.toString())}
|
.map((ct) => ct.nodeId.toString())}
|
||||||
@@ -1452,14 +1448,14 @@ export default function TunnelPage() {
|
|||||||
{form.inNodeId.some(
|
{form.inNodeId.some(
|
||||||
(ct) => ct.nodeId === node.id,
|
(ct) => ct.nodeId === node.id,
|
||||||
) && (
|
) && (
|
||||||
<Chip
|
<Chip
|
||||||
color="warning"
|
color="warning"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
已选为入口
|
已选为入口
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{form.outNodeId &&
|
{form.outNodeId &&
|
||||||
form.outNodeId.some(
|
form.outNodeId.some(
|
||||||
(ct) => ct.nodeId === node.id,
|
(ct) => ct.nodeId === node.id,
|
||||||
@@ -1482,14 +1478,14 @@ export default function TunnelPage() {
|
|||||||
ct.nodeId !== -1,
|
ct.nodeId !== -1,
|
||||||
),
|
),
|
||||||
) && (
|
) && (
|
||||||
<Chip
|
<Chip
|
||||||
color="primary"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
已选为其他跳
|
已选为其他跳
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -1603,11 +1599,12 @@ export default function TunnelPage() {
|
|||||||
isInvalid={!!errors.outNodeId}
|
isInvalid={!!errors.outNodeId}
|
||||||
label="节点"
|
label="节点"
|
||||||
placeholder="请选择出口节点(可多选)"
|
placeholder="请选择出口节点(可多选)"
|
||||||
|
dropdownPlacement="top"
|
||||||
selectedKeys={
|
selectedKeys={
|
||||||
form.outNodeId
|
form.outNodeId
|
||||||
? form.outNodeId
|
? form.outNodeId
|
||||||
.filter((ct) => ct.nodeId !== -1)
|
.filter((ct) => ct.nodeId !== -1)
|
||||||
.map((ct) => ct.nodeId.toString())
|
.map((ct) => ct.nodeId.toString())
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
@@ -1674,25 +1671,25 @@ export default function TunnelPage() {
|
|||||||
{form.inNodeId.some(
|
{form.inNodeId.some(
|
||||||
(ct) => ct.nodeId === node.id,
|
(ct) => ct.nodeId === node.id,
|
||||||
) && (
|
) && (
|
||||||
<Chip
|
<Chip
|
||||||
color="warning"
|
color="warning"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
已选为入口
|
已选为入口
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{getSelectedChainNodeIds().includes(
|
{getSelectedChainNodeIds().includes(
|
||||||
node.id,
|
node.id,
|
||||||
) && (
|
) && (
|
||||||
<Chip
|
<Chip
|
||||||
color="primary"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
已选为转发链
|
已选为转发链
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -2055,20 +2052,18 @@ export default function TunnelPage() {
|
|||||||
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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{result.success ? "✓" : "✗"}
|
{result.success ? "✓" : "✗"}
|
||||||
</span>
|
</span>
|
||||||
@@ -2110,11 +2105,10 @@ export default function TunnelPage() {
|
|||||||
<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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{result.packetLoss?.toFixed(1)}%
|
{result.packetLoss?.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
@@ -2227,19 +2221,17 @@ export default function TunnelPage() {
|
|||||||
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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{result.success ? "✓" : "✗"}
|
{result.success ? "✓" : "✗"}
|
||||||
</span>
|
</span>
|
||||||
@@ -2275,11 +2267,10 @@ export default function TunnelPage() {
|
|||||||
</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"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{result.packetLoss?.toFixed(1)}%
|
{result.packetLoss?.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { CalendarIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||||
|
|
||||||
@@ -88,7 +87,6 @@ export function DatePicker({
|
|||||||
value,
|
value,
|
||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
const nativePickerRef = React.useRef<HTMLInputElement | null>(null);
|
|
||||||
const formattedValue = React.useMemo(() => formatDateValue(value), [value]);
|
const formattedValue = React.useMemo(() => formatDateValue(value), [value]);
|
||||||
const [textValue, setTextValue] = React.useState(formattedValue);
|
const [textValue, setTextValue] = React.useState(formattedValue);
|
||||||
|
|
||||||
@@ -160,30 +158,6 @@ export function DatePicker({
|
|||||||
setTextValue(formattedValue);
|
setTextValue(formattedValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openNativePicker = () => {
|
|
||||||
if (isDisabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const picker = nativePickerRef.current;
|
|
||||||
|
|
||||||
if (!picker) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickerWithShow = picker as HTMLInputElement & {
|
|
||||||
showPicker?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof pickerWithShow.showPicker === "function") {
|
|
||||||
pickerWithShow.showPicker();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
picker.focus();
|
|
||||||
picker.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldContainer
|
<FieldContainer
|
||||||
description={description}
|
description={description}
|
||||||
@@ -193,71 +167,40 @@ export function DatePicker({
|
|||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
label={label}
|
label={label}
|
||||||
>
|
>
|
||||||
{shouldUseTextInput ? (
|
<Input
|
||||||
<div className="flex items-center gap-2">
|
aria-invalid={isInvalid}
|
||||||
<Input
|
className={cn(className)}
|
||||||
aria-invalid={isInvalid}
|
disabled={isDisabled}
|
||||||
className={cn("flex-1", className)}
|
id={id}
|
||||||
disabled={isDisabled}
|
inputMode={shouldUseTextInput ? "numeric" : undefined}
|
||||||
id={id}
|
placeholder={shouldUseTextInput ? "YYYY-MM-DD" : undefined}
|
||||||
inputMode="numeric"
|
required={isRequired}
|
||||||
placeholder="YYYY-MM-DD"
|
type={shouldUseTextInput ? "text" : "date"}
|
||||||
required={isRequired}
|
value={shouldUseTextInput ? textValue : formattedValue}
|
||||||
type="text"
|
onChange={(event) => {
|
||||||
value={textValue}
|
const nextValue = event.target.value;
|
||||||
onBlur={commitTextInput}
|
|
||||||
onChange={(event) => {
|
|
||||||
const nextValue = event.target.value;
|
|
||||||
|
|
||||||
setTextValue(nextValue);
|
if (shouldUseTextInput) {
|
||||||
notifyTextDateChange(nextValue);
|
setTextValue(nextValue);
|
||||||
}}
|
notifyTextDateChange(nextValue);
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") {
|
return;
|
||||||
event.preventDefault();
|
}
|
||||||
commitTextInput();
|
|
||||||
|
notifyNativeDateChange(nextValue);
|
||||||
|
}}
|
||||||
|
onBlur={shouldUseTextInput ? commitTextInput : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
shouldUseTextInput
|
||||||
|
? (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
commitTextInput();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
: undefined
|
||||||
/>
|
}
|
||||||
<button
|
/>
|
||||||
aria-label="打开日历选择器"
|
|
||||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-input bg-background text-default-600 shadow-sm transition-colors hover:bg-default-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isDisabled}
|
|
||||||
type="button"
|
|
||||||
onClick={openNativePicker}
|
|
||||||
>
|
|
||||||
<CalendarIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={nativePickerRef}
|
|
||||||
aria-hidden="true"
|
|
||||||
className="sr-only"
|
|
||||||
disabled={isDisabled}
|
|
||||||
tabIndex={-1}
|
|
||||||
type="date"
|
|
||||||
value={formattedValue}
|
|
||||||
onChange={(event) => {
|
|
||||||
const nextValue = event.target.value;
|
|
||||||
|
|
||||||
setTextValue(nextValue);
|
|
||||||
notifyNativeDateChange(nextValue);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
aria-invalid={isInvalid}
|
|
||||||
className={cn(className)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
id={id}
|
|
||||||
required={isRequired}
|
|
||||||
type="date"
|
|
||||||
value={formattedValue}
|
|
||||||
onChange={(event) => {
|
|
||||||
notifyNativeDateChange(event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</FieldContainer>
|
</FieldContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface SelectProps<T = unknown> extends FieldMetaProps {
|
|||||||
selectionMode?: SelectionMode;
|
selectionMode?: SelectionMode;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
variant?: string;
|
variant?: string;
|
||||||
|
dropdownPlacement?: "bottom" | "top";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectItemProps {
|
export interface SelectItemProps {
|
||||||
@@ -163,6 +164,7 @@ export function Select<T>({
|
|||||||
selectedKeys,
|
selectedKeys,
|
||||||
selectionMode = "single",
|
selectionMode = "single",
|
||||||
size,
|
size,
|
||||||
|
dropdownPlacement = "bottom",
|
||||||
}: SelectProps<T>) {
|
}: SelectProps<T>) {
|
||||||
const generatedId = React.useId();
|
const generatedId = React.useId();
|
||||||
const options = React.useMemo(
|
const options = React.useMemo(
|
||||||
@@ -284,9 +286,16 @@ export function Select<T>({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const placementClasses = dropdownPlacement === "top"
|
||||||
|
? "bottom-full mb-1"
|
||||||
|
: "top-full mt-1";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-full z-50 mt-1 max-h-56 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md"
|
className={cn(
|
||||||
|
"absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56",
|
||||||
|
placementClasses
|
||||||
|
)}
|
||||||
id={`${generatedId}-listbox`}
|
id={`${generatedId}-listbox`}
|
||||||
ref={listboxRef}
|
ref={listboxRef}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
interface TableClassNames {
|
interface TableClassNames {
|
||||||
th?: string;
|
th?: string;
|
||||||
|
td?: string;
|
||||||
|
tr?: string;
|
||||||
wrapper?: string;
|
wrapper?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TableStyleContextValue {
|
interface TableStyleContextValue {
|
||||||
thClassName?: string;
|
thClassName?: string;
|
||||||
|
tdClassName?: string;
|
||||||
|
trClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TableStyleContext = React.createContext<TableStyleContextValue>({});
|
const TableStyleContext = React.createContext<TableStyleContextValue>({});
|
||||||
@@ -24,7 +28,13 @@ export function Table({
|
|||||||
...props
|
...props
|
||||||
}: TableProps) {
|
}: TableProps) {
|
||||||
return (
|
return (
|
||||||
<TableStyleContext.Provider value={{ thClassName: classNames?.th }}>
|
<TableStyleContext.Provider
|
||||||
|
value={{
|
||||||
|
thClassName: classNames?.th,
|
||||||
|
tdClassName: classNames?.td,
|
||||||
|
trClassName: classNames?.tr,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}
|
className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}
|
||||||
>
|
>
|
||||||
@@ -151,11 +161,23 @@ export function TableColumn({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
const { trClassName } = React.useContext(TableStyleContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={cn("border-b last:border-b-0", className)} {...props} />
|
<tr
|
||||||
|
className={cn("border-b last:border-b-0", trClassName, className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
return <td className={cn("px-3 py-2 align-middle", className)} {...props} />;
|
const { tdClassName } = React.useContext(TableStyleContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
className={cn("px-3 py-2 align-middle", tdClassName, className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user