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:
sagit
2026-02-20 18:31:39 +08:00
committed by GitHub
parent 45bfd35a20
commit d216567c02
10 changed files with 429 additions and 412 deletions
@@ -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,
}) })
} }
+11 -13
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -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">
+64 -73
View File
@@ -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}
/>
);
} }